instar 1.3.1312 → 1.3.1313

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.
Files changed (25) hide show
  1. package/dist/data/standards-guard-index.json +1 -1
  2. package/dist/data/standards-guard-index.meta.json +2 -2
  3. package/dist/data/standards-registry.meta.json +1 -1
  4. package/dist/feedback-factory/drain/FeedbackDrainService.d.ts.map +1 -1
  5. package/dist/feedback-factory/drain/FeedbackDrainService.js +7 -3
  6. package/dist/feedback-factory/drain/FeedbackDrainService.js.map +1 -1
  7. package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts +20 -0
  8. package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts.map +1 -1
  9. package/dist/feedback-factory/drain/FeedbackDrainStore.js +73 -20
  10. package/dist/feedback-factory/drain/FeedbackDrainStore.js.map +1 -1
  11. package/dist/feedback-factory/store/FeedbackSourceGenerations.d.ts.map +1 -1
  12. package/dist/feedback-factory/store/FeedbackSourceGenerations.js +4 -0
  13. package/dist/feedback-factory/store/FeedbackSourceGenerations.js.map +1 -1
  14. package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts.map +1 -1
  15. package/dist/feedback-factory/store/JsonlFeedbackStore.js +8 -1
  16. package/dist/feedback-factory/store/JsonlFeedbackStore.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/data/builtin-manifest.json +2 -2
  19. package/src/data/standards-guard-index.json +1 -1
  20. package/src/data/standards-guard-index.meta.json +2 -2
  21. package/src/data/standards-registry.meta.json +1 -1
  22. package/src/scaffold/templates/jobs/instar/feedback-factory-process.md +113 -20
  23. package/upgrades/1.3.1313.md +65 -0
  24. package/upgrades/feedback-drain-end-to-end.eli16.md +42 -0
  25. package/upgrades/side-effects/feedback-drain-end-to-end.md +245 -0
@@ -16,29 +16,122 @@ toolAllowlist: "*"
16
16
  unrestrictedTools: true
17
17
  mcpAccess: none
18
18
  ---
19
- Run one feedback-factory operating-drain tick. This is a near-silent operated cadence — do NOT message the user. The server owns every transition; this job only triggers and sanity-checks one bounded run.
19
+ Run one feedback-factory operating-drain tick. This is a near-silent operated cadence — do NOT message the user. The server owns every transition; this job only triggers one bounded run and records an honest outcome.
20
20
 
21
- AUTH="${INSTAR_AUTH_TOKEN:-$(python3 -c "import json; v=json.load(open('.instar/config.json')).get('authToken',''); print(v if isinstance(v, str) else '')" 2>/dev/null)}"
22
- AGENT_ID="${INSTAR_AGENT_ID:-$(python3 -c "import json; print(json.load(open('.instar/config.json')).get('projectName',''))" 2>/dev/null)}"
23
- PORT="${INSTAR_PORT:-4042}"
24
- NONCE="feedback-drain-$(date +%s)-$$"
21
+ Run the command below ONCE, exactly as written, in a single Bash call with `timeout: 300000`. Do not rewrite it, split it, or re-type it as shell code: the script carries every rule of this job, and an improvised shell loop has broken it before (zsh refuses a variable named `status`, and the run was recorded as a success).
25
22
 
26
- 1. Read the operated drain's posture:
27
- `curl -s -w '\nHTTP %{http_code}\n' -H "Authorization: Bearer $AUTH" -H "X-Instar-AgentId: $AGENT_ID" http://localhost:$PORT/feedback-factory/drain/status`
28
- Rule on the JSON body's `posture.state`, whatever the HTTP code (a 503 body carries `posture` too). Read `developmentAgent` from `.instar/config.json`.
29
- - `live` → go to step 2.
30
- - `dark` → exit silently. The drain is switched off on purpose (fleet default, or an operator switch-off).
31
- - `unavailable` → FAIL THE RUN with reason `drain posture unavailable: <posture.reason>`.
32
- - Anything else — no JSON, no `posture` field, connection refused, 401, 403, any other code → if `developmentAgent` is true, FAIL THE RUN with reason `drain status unreadable: HTTP <code or unreachable>`; otherwise exit silently.
23
+ ```bash
24
+ python3 - <<'FEEDBACK_DRAIN_TICK'
25
+ import json, os, sys, time, urllib.error, urllib.request
33
26
 
34
- To FAIL THE RUN, run exactly this (with the reason filled in), then stop and finish normally — do not retry, do not tick:
35
- `[ -n "$INSTAR_JOB_FAILURE_FILE" ] && printf '%s' "<reason>" > "$INSTAR_JOB_FAILURE_FILE"`
36
- The scheduler reads that file when this run ends and records the run as failed with the reason. Saying "failed" in your output does NOT record a failure; only the file does.
27
+ def config():
28
+ try:
29
+ with open('.instar/config.json') as fh:
30
+ return json.load(fh)
31
+ except Exception:
32
+ return {}
37
33
 
38
- 2. Trigger one bounded drain tick:
39
- `curl -s -X POST -H "Authorization: Bearer $AUTH" -H "X-Instar-AgentId: $AGENT_ID" -H "X-Instar-Request: 1" -H "X-Instar-Request-Nonce: $NONCE" http://localhost:$PORT/feedback-factory/drain/tick`
40
- A 202 response reports `{ runId, accepted, reason? }`. Concurrent triggers return the active run rather than starting a second writer. Poll the status route, bounded to 90 seconds, until `lastRun.runId` matches and `lastRun.state` is no longer `accepted` or `running`; never start a second tick while polling.
34
+ CONFIG = config()
35
+ TOKEN = os.environ.get('INSTAR_AUTH_TOKEN') or (CONFIG.get('authToken') if isinstance(CONFIG.get('authToken'), str) else '')
36
+ AGENT_ID = os.environ.get('INSTAR_AGENT_ID') or str(CONFIG.get('projectName') or '')
37
+ BASE = 'http://localhost:' + str(os.environ.get('INSTAR_PORT') or CONFIG.get('port') or 4042)
38
+ DEVELOPMENT_AGENT = CONFIG.get('developmentAgent') is True
39
+ POLL_SECONDS = float(os.environ.get('FEEDBACK_DRAIN_POLL_SECONDS') or 60)
41
40
 
42
- 3. **Tier-1 supervision.** Accept terminal `succeeded` and `no-op`. A `degraded` run must carry a nonempty reason; a `degraded` run without one → FAIL THE RUN (step 1 command) with reason `degraded run without reason: <runId>`. Never retry in the same run; the durable queue and next cadence own retry. In simulation, canonical claimed/completed counts must not advance. In live mode, completed may never exceed the durable claimed/linked history or the configured batch bound. A broken invariant → FAIL THE RUN with reason `invariant broken: <which>`. A poll that ends at 90 seconds with the run still `accepted`/`running` is not a failure; the next cadence observes it.
41
+ def fail(reason):
42
+ path = os.environ.get('INSTAR_JOB_FAILURE_FILE')
43
+ if path:
44
+ with open(path, 'w') as fh:
45
+ fh.write(reason[:480])
46
+ print('FEEDBACK_DRAIN_RESULT failed: ' + reason)
47
+ sys.exit(0)
43
48
 
44
- 4. Exit silently. Do NOT relay anything to Telegram and do NOT summarize. The drain's durable run row, metrics, and bounded self-heal/attention path own observability; a declared failure reaches the operator through the scheduler's consecutive-failure alert.
49
+ def done(message):
50
+ print('FEEDBACK_DRAIN_RESULT ' + message)
51
+ sys.exit(0)
52
+
53
+ def call(method, route, extra=None):
54
+ headers = {'Authorization': 'Bearer ' + TOKEN, 'X-Instar-AgentId': AGENT_ID}
55
+ headers.update(extra or {})
56
+ request = urllib.request.Request(BASE + route, method=method, headers=headers, data=b'' if method == 'POST' else None)
57
+ try:
58
+ with urllib.request.urlopen(request, timeout=10) as response:
59
+ code, raw = response.status, response.read()
60
+ except urllib.error.HTTPError as error:
61
+ code, raw = error.code, error.read()
62
+ except Exception as error:
63
+ return None, 'unreachable (' + type(error).__name__ + ')'
64
+ try:
65
+ return code, json.loads(raw.decode('utf8') or 'null')
66
+ except Exception:
67
+ return code, None
68
+
69
+ def drain_status():
70
+ code, body = call('GET', '/feedback-factory/drain/status')
71
+ posture = body.get('posture') if isinstance(body, dict) else None
72
+ state = posture.get('state') if isinstance(posture, dict) else None
73
+ return code, body, posture, state
74
+
75
+ def count(value):
76
+ try:
77
+ return int(value or 0)
78
+ except (TypeError, ValueError):
79
+ return 0
80
+
81
+ def work_counts(body):
82
+ work = ((body or {}).get('drain') or {}).get('work') or {}
83
+ return count(work.get('claimed')) + count(work.get('completed'))
84
+
85
+ # 1. Posture: the JSON posture decides, whatever the HTTP code.
86
+ code, before, posture, state = drain_status()
87
+ if state == 'dark':
88
+ done('dark')
89
+ if state == 'unavailable':
90
+ fail('drain posture unavailable: ' + str(posture.get('reason') or 'unknown'))
91
+ if state != 'live':
92
+ if DEVELOPMENT_AGENT:
93
+ fail('drain status unreadable: HTTP ' + (str(code) if isinstance(code, int) else str(before)))
94
+ done('status unreadable on a fleet agent')
95
+
96
+ # 2. One bounded tick. A refused tick is a failed run (for example a demoted authority).
97
+ nonce = 'feedback-drain-' + str(int(time.time())) + '-' + str(os.getpid())
98
+ code, tick = call('POST', '/feedback-factory/drain/tick', {'X-Instar-Request': '1', 'X-Instar-Request-Nonce': nonce})
99
+ if code != 202 or not isinstance(tick, dict) or not tick.get('runId'):
100
+ detail = tick.get('error') if isinstance(tick, dict) else tick
101
+ fail('drain tick refused: HTTP ' + str(code) + ' ' + str(detail or ''))
102
+ run_id = tick['runId']
103
+ if tick.get('proxied'):
104
+ done('proxied to the owner machine, run ' + run_id + '; the owner records its outcome')
105
+
106
+ # 3. Poll this run to a terminal state (60 s). Still running at the limit is not a failure.
107
+ deadline = time.time() + POLL_SECONDS
108
+ last = None
109
+ after = None
110
+ while time.time() < deadline:
111
+ code, after, _, _ = drain_status()
112
+ last = (after or {}).get('lastRun') if isinstance(after, dict) else None
113
+ if isinstance(last, dict) and last.get('runId') == run_id and last.get('state') not in ('accepted', 'running'):
114
+ break
115
+ time.sleep(3)
116
+ else:
117
+ done('run ' + run_id + ' still in flight; the next cadence observes it')
118
+
119
+ # 4. Tier-1 supervision of the terminal run.
120
+ run_state = last.get('state')
121
+ reason = str(last.get('reason') or '').strip()
122
+ if run_state in ('succeeded', 'no-op'):
123
+ if isinstance(after, dict) and after.get('consumerLive') is False and work_counts(after) > work_counts(before):
124
+ fail('invariant broken: claimed/completed work advanced while the consumer is in simulation (run ' + run_id + ')')
125
+ done(run_state + ' ' + run_id)
126
+ fail('drain run ' + str(run_state) + ': ' + (reason or 'no reason given') + ' (run ' + run_id + ')')
127
+ FEEDBACK_DRAIN_TICK
128
+ ```
129
+
130
+ The script prints one `FEEDBACK_DRAIN_RESULT` line. It records a failed run itself by writing the reason to `$INSTAR_JOB_FAILURE_FILE` (the scheduler reads that file when the run ends); saying "failed" in your output does NOT record a failure. Outcomes it enforces:
131
+ - Posture `dark` → silent exit. `unavailable` → failed run. Unreadable status → failed run on a development agent, silent exit on the fleet.
132
+ - A refused tick (any HTTP code other than 202, e.g. `current registered readiness agent required` after the authority was demoted) → failed run.
133
+ - A tick proxied to the owner machine → success here; its outcome lives in the owner's run history and status.
134
+ - Terminal `succeeded` / `no-op` → success. Terminal `degraded`, `failed` or `abandoned` → failed run carrying the drain's reason.
135
+ - In simulation, claimed/completed work counts must not advance; if they do → failed run.
136
+
137
+ Never retry in the same run; the durable queue and the next cadence own retry. Then exit silently. Do NOT relay anything to Telegram and do NOT summarize. A failed run reaches the operator through the scheduler's consecutive-failure alert.
@@ -0,0 +1,65 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The operated feedback drain failed every run with `source record checksum conflicts with
9
+ its projection`. The canonical `feedback.jsonl` is an append-only, last-write-wins log:
10
+ when processing flips a report from unprocessed to processing it re-appends the report's
11
+ full row with the same `sourceRecordId`. The drain read its own re-append as a conflict,
12
+ and one conflict failed the whole run, so 742 clusters never left `collecting`.
13
+
14
+ - The processing writer now appends each update as a new source record (fresh
15
+ `sourceRecordId`), as the spec requires. The 1,000 lines the old writer appended under the
16
+ original ids are accepted as superseded versions only because they change `status` and
17
+ `clusterId` alone. Any other same-id change, or an id bound to another report, is
18
+ corruption: that line alone is held in `source_conflicts` (audited, integrity hold set), the
19
+ cursor moves on, and every run ends `degraded` with `source-record-quarantined` until an
20
+ operator repairs it.
21
+ - Self-heal: the conflict the earlier build recorded for a legacy update line is cleared
22
+ (audited) when the cursor re-reads that line.
23
+ - Compaction: the cursor now starts after the checksum-verified copied prefix of a new
24
+ generation instead of re-reading the whole corpus, and an already-compact generation
25
+ is no longer copied into a byte-identical new file every interval.
26
+ - A projected batch replayed after a crash mid-pass no longer clusters (and counts) a
27
+ report the store already processed.
28
+ - The `feedback-factory-process` job body is now one fixed script. The runner model used to
29
+ improvise a zsh loop (`status=$(...)` fails: `status` is read-only in zsh), and a degraded
30
+ run was recorded as a success. The script fails the job run (via
31
+ `$INSTAR_JOB_FAILURE_FILE`) on a refused tick and on a `degraded`, `failed` or
32
+ `abandoned` run, with the drain's reason. A tick proxied to the owner machine finishes at
33
+ once (its outcome lives in the owner's run history and status).
34
+
35
+ Existing stores heal on the next run. The job body reaches existing agents through the
36
+ normal built-in job refresh on update. No operator action.
37
+
38
+ ## What to Tell Your User
39
+
40
+ The feedback sorter was stuck: it tripped over its own bookkeeping on every run, so no
41
+ feedback reached the task list. It now runs end to end on the real backlog, and if a run
42
+ goes wrong the scheduled job says so instead of reporting success. Nothing for you to do.
43
+
44
+ ## Summary of New Capabilities
45
+
46
+ - The feedback drain completes full passes on the live store (reports processed, clusters judged, ready work handed to Initiative tasks).
47
+ - One bad source record is quarantined instead of stopping every run.
48
+ - The drain job fails honestly when a run is degraded or refused.
49
+
50
+ ## Evidence
51
+
52
+ Reproduced on a read-only copy (`sqlite3 .backup`) of the Mac Studio's live store, taken
53
+ 2026-10-01 16:02 PDT, driving the real drain code:
54
+ - Before (origin/main): 3 of 3 ticks threw `source record checksum conflicts with its
55
+ projection` at the first processing re-append (`feedback-source:7720c778-…`); 742 clusters
56
+ stayed `collecting`, 0 work rows.
57
+ - After: 18 ticks, 0 failed or degraded; 1,004/1,004 reports processed, all 746 clusters
58
+ evaluated, 45 ready → queued → claimed → completed Initiative tasks (read back), the stale
59
+ conflict row cleared with an audit entry, 0 lines quarantined, 2 compacted generations
60
+ (6 before), projection lag 0.
61
+ - Real arbiter (gpt-6-astra via codex-cli): 5 generic clusters → `collecting`, 5 specific
62
+ multi-report bugs → `ready` (0.86–0.94); both replies replayed in the e2e.
63
+ - Job: the live 11:00 PDT run died on `read-only variable: status` and was recorded as a
64
+ success for a degraded drain run; the new script records that run as failed
65
+ (`drain run degraded: readiness-authority-failed`).
@@ -0,0 +1,42 @@
1
+ # ELI16 — the feedback sorter tripped over its own notes
2
+
3
+ ## The problem
4
+
5
+ Fleet agents send bug reports. The feedback sorter ("drain") reads them from a log file,
6
+ groups similar ones, asks a model which groups are ready to become a task, and creates
7
+ those tasks.
8
+
9
+ The log file is append-only: nothing is ever edited in place. When the sorter files a
10
+ report into a group, it writes a new copy of that report's line at the end of the file,
11
+ marked "processing". The newest copy wins.
12
+
13
+ The sorter also keeps its own index of every line it has read, with a fingerprint of each.
14
+ When it reached its own "processing" copy, it saw the same report id with a different
15
+ fingerprint and decided the file had been tampered with. It stopped the whole run. Every
16
+ run after that stopped at the same line, so no group ever became a task.
17
+
18
+ The scheduled job that drives the sorter reported "success" anyway. It runs on a small
19
+ model that wrote its own shell loop, and that loop crashed on a variable name the shell
20
+ reserves (`status`).
21
+
22
+ ## What this change does
23
+
24
+ - The sorter's own updates now get a new record id each time, as the design always said
25
+ they should. So a new copy is never mistaken for a changed old one.
26
+ - The 1,000 old copies already in the file changed only the "processing" mark and the group
27
+ name. Those are accepted as updates. Any other change under an old id is still treated as
28
+ tampering.
29
+ - Tampering no longer stops everything. Only the bad line is set aside and logged, and the
30
+ rest of the run continues. Every run is then marked "degraded", so the scheduled job keeps
31
+ failing and alerting until a person repairs that line.
32
+ - The false alarm already stored on the live machine clears itself on the next run, with
33
+ an audit entry.
34
+ - Two waste fixes found on the way: after the log is compacted, the sorter no longer
35
+ re-reads everything from the start, and an unchanged log is no longer copied again.
36
+ - The scheduled job now runs one fixed script. A refused, degraded, or failed run fails
37
+ the job, with the reason.
38
+
39
+ ## What you need to do
40
+
41
+ Nothing. The next run heals the stored state, and the job update arrives with the
42
+ normal instar update.
@@ -0,0 +1,245 @@
1
+ # Side-Effects Review — feedback drain: a full pass completes on real data
2
+
3
+ **Version / slug:** `feedback-drain-end-to-end`
4
+ **Date:** `2026-10-01`
5
+ **Author:** `Echo`
6
+ **Second-pass reviewer:** `independent reviewer subagent (see below)`
7
+
8
+ ## Summary of the change
9
+
10
+ Seventh first-run blocker on the operated drain (Mac Studio). After authority generation 2
11
+ went active (epoch 22681), both runs (15:58 and 16:00 PDT) failed with `source record
12
+ checksum conflicts with its projection`; `source_conflicts` held one row
13
+ (`feedback-source:7720c778-…`, report `fb-1145e5c3-4a2`). Per the standing "three failed
14
+ rounds → structural review" rule, this change drove the REAL drain code against a copy of
15
+ the live store until a full pass completed, fixing every blocker met on the way:
16
+
17
+ 1. **Root cause (checksum conflict) — the writer.** The spec (§5, source discovery) says a
18
+ later last-write-wins update is a NEW source record with its own projection sequence, and a
19
+ duplicate `sourceRecordId` with different bytes is corruption that holds. The processing
20
+ writer broke the first half: `JsonlFeedbackStore.markProcessed` re-appended each processed
21
+ report under its ORIGINAL `sourceRecordId`. The first live runs appended 1,000 such lines;
22
+ the cursor then reached the first one (byte 2,992,509) and the drain, correctly by the
23
+ spec's second half, called it corruption, and threw for the whole run. Fix: the processing
24
+ update is appended without the old id, so `FeedbackSourceGenerations.append` mints a fresh
25
+ one. Updates are now projected as their own records; clustering ignores them (only
26
+ `unprocessed` rows are clustered).
27
+ 2. **Legacy lines and quarantine.** The 1,000 lines already on disk keep their reused ids. A
28
+ same-id line is accepted as a superseded version only when it differs from the projected
29
+ record in the LWW fields alone (`status`, `clusterId`) — exactly what the old writer
30
+ produced (all 1,000 live lines pass; 0 quarantined on the copy). Any other difference
31
+ (`source-record-content-conflict`) or the id bound to another report
32
+ (`source-record-identity-conflict`) is corruption: that line alone is held in
33
+ `source_conflicts` with an audit row and `source_integrity_hold`, the cursor moves on, and
34
+ EVERY run ends `degraded` / `source-record-quarantined` until an operator repairs the
35
+ held line (spec §207: a persistent mismatch raises operator attention). One record no
36
+ longer fails the whole run. **Self-heal:** when the cursor re-reads a legacy LWW line that
37
+ the earlier build recorded as `source-record-checksum-conflict`, that row is deleted with
38
+ an audit entry (`cleared-misclassified-conflict`); the hold is lifted only if it is a
39
+ `source-record-*` hold and no conflict of any kind remains. The live store heals on its
40
+ next run.
41
+ 3. **Compaction re-read (found on the copy).** `acceptSourceHandoff` reset the cursor to
42
+ byte 0 of the new generation, so every compaction re-read the whole corpus at 500 rows
43
+ per run. It now takes the handoff's `startOffset` (the end of the copied prefix, already
44
+ checksum-verified by `readManifest`). Optional parameter; callers without it keep 0.
45
+ 4. **Byte-identical generations (found on the copy).** `compact()` on a generation with
46
+ nothing superseded wrote a new 3 MB copy each interval (5 identical files in the replay).
47
+ It now returns null when a manifest already exists and the output equals the input; the
48
+ legacy file still always moves to a generation. The service restarts the compaction
49
+ interval either way.
50
+ 5. **Replay double-count.** A projected batch replayed after a crash mid-pass (process
51
+ killed between clustering and `acknowledgeProcessedProjection`) re-clustered reports the
52
+ store already marked processing, inflating `reportCount`; with updates now projected, the
53
+ same batch could also hold a report and its update ("duplicated id" throw, every run).
54
+ `withProjectedFeedbackScope` skips a row unless both the row and the store's folded state
55
+ are `unprocessed`, before the duplicate check.
56
+ 6. **Job honesty.** The `feedback-factory-process` body is now one fixed python script the
57
+ haiku runner executes verbatim. The live run (transcript 8e49dd8d, 11:00 PDT) improvised
58
+ `status=$(curl …)` in zsh (read-only variable), and the old body also accepted a
59
+ `degraded` run as success. The script writes `$INSTAR_JOB_FAILURE_FILE` on: posture
60
+ `unavailable`; unreadable status on a development agent; any tick response other than 202
61
+ (e.g. 403 after an authority demotion); terminal `degraded` / `failed` / `abandoned`; and
62
+ simulation work counts that advance. `dark` exits silently. A tick proxied to the owner
63
+ machine finishes at once (its outcome lives in the owner's run history). Still in flight
64
+ at 60 s is not a failure. Worst case ~93 s (10 s per HTTP call), inside the
65
+ `timeout: 300000` the body asks the runner to set.
66
+
67
+ ## Decision-point inventory
68
+
69
+ - Processing update writer — modify — original sourceRecordId → fresh sourceRecordId (spec).
70
+ - Source projection: same sourceRecordId + same report + only status/clusterId differ — modify — throw (whole run) → superseded version (legacy lines).
71
+ - Source projection: same sourceRecordId + other content or another report — modify — throw (whole run) → quarantine that line; every run degraded until repaired.
72
+ - Compaction handoff cursor start — modify — 0 → manifest `startOffset`.
73
+ - Compaction of an already-compact generation — modify — new identical generation → no-op.
74
+ - Projected clustering scope — modify — includes already-processed rows → skips them (before the duplicate-id check).
75
+ - Job outcome classification — modify — model-improvised → fixed script; `degraded` now fails the job.
76
+ - Reconciliation (`reconcileSourceProjection`) — unchanged: projected byte ranges are still re-hashed and mismatches still recorded.
77
+
78
+ ---
79
+
80
+ ## 1. Over-block
81
+
82
+ The old code failed whole runs for one line; the live trigger was not even corruption but
83
+ the drain's own writer. Now only a corrupt line is held, and only that line. The job now
84
+ fails on any `degraded` run. Every degraded reason is something an operator should see
85
+ (authority unavailable or failed, spend brake, readiness source missing, cancellation,
86
+ quarantine). The job is priority `low`, so the scheduler alerts after 3 consecutive
87
+ failures: a spend brake or an unrepaired quarantine fails every 30-minute run and alerts
88
+ within ~90 minutes; a single transient degraded run does not alert.
89
+
90
+ ## 2. Under-block
91
+
92
+ - A legacy same-id line that changes only `status`/`clusterId` is accepted without an alarm.
93
+ That is the documented LWW update shape; it is never re-projected, so it cannot re-enter
94
+ clustering. A same-id line with any other change is still held.
95
+ - "Hold the entity family" (spec §6, corrupt source): the held line never enters the
96
+ projection; later updates of that report under their own ids are projected but are never
97
+ clustered (not `unprocessed`). The report's first, valid line stays the projected record.
98
+ - Repair of a held line is operator work (no route added); until then every run is degraded
99
+ and `pruneOperationalHistory` is skipped (existing degraded behaviour), so run/audit
100
+ history grows while the job alerts.
101
+ - The job script still depends on the runner executing it verbatim. The body says so and
102
+ the script is one heredoc; a `script`-type job would need a manifest migration for every
103
+ agent, which this does not justify while the prompt route works.
104
+ - A proxied tick's outcome is not supervised by any job: it is visible only in the owner's
105
+ run history and `/feedback-factory/drain/status` on the owner.
106
+
107
+ ## 3. Level-of-abstraction fit
108
+
109
+ The projection rule sits in the store that owns the cursor and the conflict table. The
110
+ compaction fix sits in the generation writer, and the cursor fix in the store's handoff
111
+ acceptance. The job fix is in the shipped template that `installBuiltinJobs` overwrites
112
+ on every update.
113
+
114
+ ## 4. Signal vs authority compliance
115
+
116
+ Deterministic integrity checks over ids and checksums; no judgment. Quarantine is a
117
+ signal (conflict row, hold, audit, degraded run) and no longer blocks unrelated reports.
118
+
119
+ ## 4b. Judgment-point check
120
+
121
+ No new heuristic at a competing-signals point. The readiness model's prompt and parsing are
122
+ untouched; the e2e replays two real gpt-6-astra replies through the unchanged parser.
123
+
124
+ ## 5. Interactions
125
+
126
+ - `reconcileSourceProjection` re-hashes only projected byte ranges, which still hold their
127
+ original bytes (append-only). Unaffected.
128
+ - `acceptSourceHandoff` keeps the "target generation already projected" refusal.
129
+ - `pruneOperationalHistory` is skipped on degraded runs, as before; quarantine is the only
130
+ new degraded case, and it persists until repaired.
131
+ - `source_integrity_hold` is also set by the generation-missing path and by reconciliation;
132
+ this path lifts only a `source-record-*` hold, and only when the conflict table is empty.
133
+ Nothing reads the hold today.
134
+ - Projection rows grow by one per processing update (the spec's model); compaction's copied
135
+ prefix is skipped by `startOffset`.
136
+
137
+ ## 6. External surfaces
138
+
139
+ `/feedback-factory/drain/status`: `sourceChecksumConflicts` drops to 0 on the live store
140
+ after one run. The job's run history now records failures it previously hid.
141
+
142
+ ## 6b. Operator-surface quality
143
+
144
+ The failure reasons the job records are one line with the drain's own reason and run id.
145
+
146
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
147
+
148
+ Machine-local by design: the source log and drain DB live only on the operated host. The job
149
+ on a non-owner proxies the tick as before and now finishes at once. The proxied run's outcome
150
+ is visible only in the owner's run history and status; no job supervises it.
151
+
152
+ ## 8. Rollback cost
153
+
154
+ A code revert. A healed store keeps working on the old code until the next processing
155
+ re-append is read, then fails as before. No data is rewritten; the only deletions are the
156
+ misclassified conflict rows, each audited.
157
+
158
+ ## Conclusion
159
+
160
+ Root cause: the processing writer re-appended updates under the original source id, which
161
+ the spec calls corruption, and one such line failed every run. The writer now mints a new id,
162
+ the 1,000 legacy lines are accepted only because they change status/clusterId alone, real
163
+ corruption holds just its line while runs stay loudly degraded, and the live store heals on
164
+ its next run. Driving
165
+ the real code over a copy of the live store until a full pass completed found and fixed two
166
+ compaction costs and a replay double-count on the way, and the job now reports degraded and
167
+ failed runs honestly.
168
+
169
+ ## Operator repair of a held source line
170
+
171
+ Only needed if a run reports `source-record-quarantined` (none on the live store today).
172
+ 1. Read the held rows: `sqlite3 <stateDir>/state/feedback-factory/store/feedback-drain.db
173
+ "SELECT * FROM source_conflicts"` and the matching `drain_audit` rows
174
+ (`kind='source-record'`). Compare the held line in `feedback.jsonl` (search its
175
+ `sourceRecordId`) with the projected record (`SELECT record_json FROM source_projection
176
+ WHERE source_record_id=?`).
177
+ 2. Decide: the projected record stays authoritative (the default — nothing else to do), or
178
+ the report needs a fresh correct line, appended through the normal intake.
179
+ 3. Clear the hold with the server stopped:
180
+ `DELETE FROM source_conflicts WHERE source_record_id='<id>';`
181
+ `INSERT INTO drain_audit(kind,entity_id,from_state,to_state,reason,created_at) VALUES
182
+ ('source-record','<id>','<reason>','operator-cleared','operator-repair:<decision-ref>',<epoch-ms>);`
183
+ and, if the table is now empty, `DELETE FROM drain_meta WHERE key='source_integrity_hold';`.
184
+ The next run is no longer degraded by it.
185
+
186
+ ## Spec alignment
187
+
188
+ `docs/specs/feedback-factory-operating-drain.md` §5 (source discovery, reconciliation) and §6
189
+ (corrupt source):
190
+ - "Later LWW updates are new source records and receive a new projection sequence" — the
191
+ writer now does this; the defect was the writer reusing the id.
192
+ - "Duplicate `sourceRecordId` with a different checksum is corruption and holds" — kept for
193
+ every change except the legacy status/clusterId-only update the old writer produced, which
194
+ is the LWW update the spec describes, written under the wrong id. Those lines are
195
+ superseded, not deleted; the source file is never rewritten.
196
+ - "Conflicting rows are … never auto-deleted" — the only deletion is of rows the earlier
197
+ build recorded for those legacy LWW lines, each audited (`cleared-misclassified-conflict`).
198
+ Real conflicts (content or identity) are never deleted.
199
+ - "A persistent mismatch … raises one operator attention" — every run stays degraded while a
200
+ held line exists (projection content/identity conflicts and reconciliation checksum
201
+ conflicts), the job fails, and the scheduler's consecutive-failure alert fires. Before this
202
+ change a reconciliation conflict never degraded a run.
203
+ - "Source-record uniqueness makes copied live rows idempotent" — unchanged; the compaction
204
+ cursor additionally skips the verified copied prefix.
205
+
206
+ ## Second-pass review (if required)
207
+
208
+ **Reviewer:** independent reviewer subagent (general-purpose, read-only)
209
+ **Independent read of the artifact (round 1):** concerns raised, all addressed
210
+
211
+ Round 1 raised: (a) a real quarantine alerted only once, against spec §207 → every run now
212
+ stays degraded until repair; (b) a proxied tick polled a run it cannot see for 90 s and
213
+ recorded success → finishes at once; (c) worst-case script time (~150 s) exceeded the Bash
214
+ tool's 120 s default → 10 s HTTP timeouts, 60 s poll, body asks for `timeout: 300000`;
215
+ (d) a non-numeric work count crashed the script → safe parse; (e) the hold-clear removed holds
216
+ other paths own → only `source-record-*` holds; (f) the change contradicted spec §5
217
+ ("later LWW updates are new source records") → redesigned: the writer mints a fresh id and
218
+ only legacy status/clusterId-only lines are tolerated; (g) wording in §1, §5, §7 → corrected.
219
+ **Round 2:** concur. Notes applied: legacy-update comparison also ignores the re-keyed id
220
+ fields (`feedbackId`/`feedback_id`/`id`); `reconciliation-checksum-conflict` rows now count as
221
+ held (runs degrade until repaired); the operator repair procedure is written above; proxied-run
222
+ wording corrected.
223
+
224
+ ## Evidence pointers
225
+
226
+ - Live store copy (`sqlite3 .backup`, read-only) at 2026-10-01 16:02 PDT: origin/main fails
227
+ 3/3 ticks with the live error. With this change: 1,004/1,004 reports processed, all 746
228
+ clusters evaluated, 45 ready → queued → claimed → completed → read-back Initiative tasks
229
+ (stubbed gpt-6-astra-shaped arbiter through the real parser), stale conflict cleared
230
+ (audit row), 2 generations after compaction (was 6), projection lag 0.
231
+ - Real arbiter (production `FeedbackReadinessArbiter` + codex-cli provider, gpt-6-astra
232
+ resolved and accepted): 5 generic clusters → all `collecting` (0.99, 17 s); 5 specific
233
+ multi-report bugs → all `ready` (0.86–0.94, 18–20 s). Both replies are fixtures.
234
+ - Tests: `tests/unit/feedback-drain-source-versions.test.ts`,
235
+ `tests/unit/feedback-drain-store.test.ts`, `tests/integration/feedback-factory-drain-service.test.ts`,
236
+ `tests/integration/feedback-factory-process-job-body.test.ts` (real script against a drain
237
+ server), `tests/e2e/feedback-drain-live-shapes.test.ts` (AgentServer + HTTP, real shapes).
238
+ All 14 new tests fail on the pre-fix source.
239
+
240
+ ## Class-Closure Declaration (display-only mirror)
241
+
242
+ Runtime code defect. Class: a writer that breaks the source-record contract, met by an
243
+ integrity check that fails closed for the whole batch. Siblings in the same path:
244
+ compaction handoff (re-read), compaction no-op (copy), projected replay (double count) —
245
+ all closed here. Reconciliation already scopes to fixed byte ranges and is not a sibling.