instar 1.3.776 → 1.3.778

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 (49) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +10 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +10 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/IntelligenceRouter.d.ts +17 -0
  8. package/dist/core/IntelligenceRouter.d.ts.map +1 -1
  9. package/dist/core/IntelligenceRouter.js +22 -1
  10. package/dist/core/IntelligenceRouter.js.map +1 -1
  11. package/dist/core/PostUpdateMigrator.d.ts +11 -0
  12. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  13. package/dist/core/PostUpdateMigrator.js +111 -1
  14. package/dist/core/PostUpdateMigrator.js.map +1 -1
  15. package/dist/core/StopGateDb.d.ts +31 -0
  16. package/dist/core/StopGateDb.d.ts.map +1 -1
  17. package/dist/core/StopGateDb.js +103 -5
  18. package/dist/core/StopGateDb.js.map +1 -1
  19. package/dist/core/UnjustifiedStopGate.d.ts +30 -1
  20. package/dist/core/UnjustifiedStopGate.d.ts.map +1 -1
  21. package/dist/core/UnjustifiedStopGate.js +97 -3
  22. package/dist/core/UnjustifiedStopGate.js.map +1 -1
  23. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  24. package/dist/core/devGatedFeatures.js +6 -0
  25. package/dist/core/devGatedFeatures.js.map +1 -1
  26. package/dist/core/stopGateTranscriptTail.d.ts +48 -0
  27. package/dist/core/stopGateTranscriptTail.d.ts.map +1 -0
  28. package/dist/core/stopGateTranscriptTail.js +118 -0
  29. package/dist/core/stopGateTranscriptTail.js.map +1 -0
  30. package/dist/core/types.d.ts +23 -0
  31. package/dist/core/types.d.ts.map +1 -1
  32. package/dist/core/types.js.map +1 -1
  33. package/dist/data/llmBenchCoverage.d.ts +28 -0
  34. package/dist/data/llmBenchCoverage.d.ts.map +1 -1
  35. package/dist/data/llmBenchCoverage.js +94 -0
  36. package/dist/data/llmBenchCoverage.js.map +1 -1
  37. package/dist/server/routes.d.ts.map +1 -1
  38. package/dist/server/routes.js +65 -0
  39. package/dist/server/routes.js.map +1 -1
  40. package/dist/server/stopGate.d.ts +8 -0
  41. package/dist/server/stopGate.d.ts.map +1 -1
  42. package/dist/server/stopGate.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/data/builtin-manifest.json +63 -63
  45. package/src/data/llmBenchCoverage.ts +174 -0
  46. package/upgrades/1.3.777.md +28 -0
  47. package/upgrades/1.3.778.md +47 -0
  48. package/upgrades/side-effects/nature-axis-routing-fd5b-injection-exposure.md +62 -0
  49. package/upgrades/side-effects/self-deferral-guard-shadow.eli16.md +48 -0
@@ -685,6 +685,180 @@ export const LLM_ROUTING_NATURE: Readonly<Record<string, RoutingNature>> = {
685
685
  'correction-learning': { nature: 'D', chain: 'SORT' },
686
686
  };
687
687
 
688
+ // ───────────────────────────────────────────────────────────────────────────
689
+ // S4 FD5b — INJECTION-EXPOSURE static map (docs/specs/nature-axis-routing.md
690
+ // FD5(b) §283-294, semantic-drift row detail §370-384).
691
+ //
692
+ // The `injectionExposure` axis of the per-component routing classification — the
693
+ // PARALLEL exhaustive map the FD5 door-availability walk consults to decide
694
+ // whether a candidate position on a NON-injection-safe door (a door whose
695
+ // ChainPosition carries `injectionSafe: false`, e.g. groq-api/gpt-oss-120B) is
696
+ // eligible for THIS component. It is enforced STATICALLY (not a per-call caller
697
+ // flag) exactly like the nature map: one forgotten callsite must not be able to
698
+ // silently route an injection-exposed call onto a non-injection door.
699
+ //
700
+ // POLARITY — FAIL-SAFE (spec §286): the map DEFAULTS to `exposed: true`. A
701
+ // component is `exposed: false` ONLY when explicitly audited as carrying NO
702
+ // untrusted / injection-bearing content, and that argument is required in the row
703
+ // (`reason`, pinned shrink-only in the ratchet). `resolveInjectionExposure`
704
+ // treats a missing/unknown component as EXPOSED (fail-closed skip) — the safe
705
+ // direction. A per-call `attribution.injectionExposed: true` may only TIGHTEN
706
+ // (mark an otherwise-trusted call exposed), never relax a statically-exposed
707
+ // component (composed in IntelligenceRouter.isComponentInjectionExposed).
708
+ //
709
+ // THE CLASSIFICATION IS THE SAME PREDICATE AS `LLM_UNTRUSTED_INPUT` above — a
710
+ // component carries injection-bearing content iff it judges untrusted content —
711
+ // so this map's `exposed` is authored to MIRROR that reviewed axis, and the
712
+ // ratchet (tests/unit/nature-routing-injection-exposure-ratchet.test.ts)
713
+ // CROSS-CHECKS the two so they can never silently diverge (a callsite that
714
+ // becomes `untrustedInput: true` must also become `exposed: true`, or CI fails).
715
+ // This is the FD5b arm of the FD7 semantic-drift guard that is honoured TODAY;
716
+ // the prompt-anchor fingerprint LINT (spec §376-384) is the separate FD7
717
+ // semantic-drift increment.
718
+ //
719
+ // R8 (spec §308-310): the input-classifier-nature components (InputClassifier,
720
+ // MessageSentinel, TaskClassifier) are injection-exposed and MUST be
721
+ // `exposed: true` — the ratchet pins this so a future edit can't relax them.
722
+ //
723
+ // EACH ROW carries the spec's INPUT-SHAPE DECLARATION (§371: "can user / model /
724
+ // tool content enter this call?"). It is load-bearing here: the ratchet enforces
725
+ // `exposed ⟺ (userContent || modelContent || toolContent)` — an exposed:false row
726
+ // declares all three false (nothing untrusted can enter), an exposed:true row
727
+ // declares at least the channel(s) through which untrusted content arrives.
728
+ // ───────────────────────────────────────────────────────────────────────────
729
+
730
+ /** Can content of each provenance enter this LLM call (spec §371 input-shape declaration)?
731
+ * - userContent → the prompt embeds human/user-authored content (inbound messages, conversation);
732
+ * - modelContent → the prompt embeds model/agent-generated content (assistant turns, session output, summaries);
733
+ * - toolContent → the prompt embeds tool output / file / peer-agent / process content. */
734
+ export interface InjectionInputShape {
735
+ readonly userContent: boolean;
736
+ readonly modelContent: boolean;
737
+ readonly toolContent: boolean;
738
+ }
739
+
740
+ /** One component's static injection-exposure classification (FD5b).
741
+ * `exposed` DEFAULTS true (fail-safe); `exposed:false` REQUIRES an argued `reason`
742
+ * and an all-false `inputShape` (audited: no untrusted content can enter). */
743
+ export interface InjectionExposure {
744
+ readonly exposed: boolean;
745
+ readonly inputShape: InjectionInputShape;
746
+ /** Required argument WHY the component carries no untrusted content (exposed:false only). */
747
+ readonly reason?: string;
748
+ }
749
+
750
+ const EXPOSED_USER: InjectionInputShape = { userContent: true, modelContent: false, toolContent: false };
751
+ const EXPOSED_MODEL: InjectionInputShape = { userContent: false, modelContent: true, toolContent: false };
752
+ const EXPOSED_TOOL: InjectionInputShape = { userContent: false, modelContent: false, toolContent: true };
753
+ const EXPOSED_USER_MODEL: InjectionInputShape = { userContent: true, modelContent: true, toolContent: false };
754
+ const EXPOSED_USER_TOOL: InjectionInputShape = { userContent: true, modelContent: false, toolContent: true };
755
+ const EXPOSED_MODEL_TOOL: InjectionInputShape = { userContent: false, modelContent: true, toolContent: true };
756
+ const EXPOSED_ALL: InjectionInputShape = { userContent: true, modelContent: true, toolContent: true };
757
+ const NOT_EXPOSED: InjectionInputShape = { userContent: false, modelContent: false, toolContent: false };
758
+
759
+ /** Sugar for the fail-safe default: an exposed row carrying its untrusted input-shape. */
760
+ const exposed = (inputShape: InjectionInputShape): InjectionExposure => ({ exposed: true, inputShape });
761
+ /** Sugar for the audited safe row: not exposed, all channels closed, with the argued reason. */
762
+ const notExposed = (reason: string): InjectionExposure => ({ exposed: false, inputShape: NOT_EXPOSED, reason });
763
+
764
+ export const LLM_ROUTING_INJECTION_EXPOSURE: Readonly<Record<string, InjectionExposure>> = {
765
+ // ── Sentinels ──
766
+ ExternalHogClassifier: exposed(EXPOSED_TOOL), // attacker-controllable process name + argv
767
+ InputGuard: exposed(EXPOSED_USER),
768
+ SessionActivitySentinel: exposed(EXPOSED_MODEL), // session tmux output
769
+ StallTriageNurse: exposed(EXPOSED_MODEL), // session output
770
+ CommitmentSentinel: exposed(EXPOSED_USER_MODEL), // both sides of a conversation
771
+ PresenceProxy: exposed(EXPOSED_MODEL), // session-stall over session output
772
+ MessageSentinel: exposed(EXPOSED_USER), // inbound user message (R8 input-classifier — must stay exposed)
773
+ ProjectDriftChecker: exposed(EXPOSED_MODEL_TOOL), // session work + files
774
+ TemporalCoherenceChecker: exposed(EXPOSED_USER_MODEL),
775
+ CompletionEvaluator: exposed(EXPOSED_MODEL_TOOL), // asserted work over session output/transcript
776
+ SessionWatchdog: exposed(EXPOSED_MODEL), // stuck-judge over session output
777
+ ResumeQueueDrainer: exposed(EXPOSED_MODEL_TOOL), // session state before a resume
778
+ TopicIntentArcCheck: exposed(EXPOSED_USER_MODEL),
779
+ SlackAdapter: exposed(EXPOSED_MODEL), // stall-confirm over session output
780
+ InputClassifier: exposed(EXPOSED_USER), // inbound user message (R8 input-classifier — must stay exposed)
781
+ SessionSummarySentinel: exposed(EXPOSED_MODEL), // summarizes tmux output
782
+ TelegramAdapter: exposed(EXPOSED_MODEL), // stall-confirm over session output
783
+
784
+ // ── Gates ──
785
+ PromptGate: exposed(EXPOSED_USER_TOOL), // injection detection over inbound content
786
+ ExternalOperationGate: exposed(EXPOSED_USER_TOOL), // in-content "user already approved" claims
787
+ WarrantsReplyGate: exposed(EXPOSED_USER),
788
+ UnjustifiedStopGate: exposed(EXPOSED_MODEL), // stop justification over session state
789
+ MessagingToneGate: exposed(EXPOSED_ALL), // reviews an outbound draft quoting user/tool content
790
+ MoveIntentClassifier: exposed(EXPOSED_USER), // inbound user message + conversation
791
+ HubIntentClassifier: exposed(EXPOSED_USER), // inbound hub message
792
+ ProfileIntentClassifier: exposed(EXPOSED_USER), // inbound user message + conversation
793
+ CoherenceReviewer: exposed(EXPOSED_ALL), // reviews outbound coherence (draft + context)
794
+ LLMSanitizer: exposed(EXPOSED_USER_TOOL), // definitionally judges untrusted inbound content
795
+ OverrideDetector: exposed(EXPOSED_USER),
796
+ TaskClassifier: exposed(EXPOSED_USER), // classifies a user task (R8 input-classifier — must stay exposed)
797
+
798
+ // ── Reflectors ──
799
+ JobReflector: exposed(EXPOSED_MODEL_TOOL),
800
+ crossModelReviewer: exposed(EXPOSED_TOOL), // reviews a spec document (file)
801
+ SelfKnowledgeTree: exposed(EXPOSED_MODEL_TOOL), // extracts over transcripts
802
+ TreeTriage: exposed(EXPOSED_TOOL),
803
+ TopicSummarizer: exposed(EXPOSED_USER_MODEL),
804
+ ContextualEvaluator: exposed(EXPOSED_USER_MODEL),
805
+ RelationshipManager: exposed(EXPOSED_USER_MODEL), // extracts relationship facts from conversation
806
+ StandardsConformanceReviewer: exposed(EXPOSED_TOOL), // artifact-vs-standard over files
807
+ DiscoveryEvaluator: exposed(EXPOSED_MODEL_TOOL), // subagent output
808
+ Usher: exposed(EXPOSED_USER), // routes an inbound turn
809
+ TopicIntentExtractor: exposed(EXPOSED_USER),
810
+ PreCompactionFlush: exposed(EXPOSED_MODEL_TOOL), // durable facts from a transcript
811
+ TreeSynthesis: exposed(EXPOSED_TOOL),
812
+ LLMConflictResolver: exposed(EXPOSED_TOOL), // divergent peer state = untrusted peer data
813
+ openConversationBrief: exposed(EXPOSED_TOOL), // A2A peer content
814
+ 'a2a-checkin': exposed(EXPOSED_TOOL), // A2A peer-authored threads
815
+ 'correction-learning': exposed(EXPOSED_USER_MODEL),
816
+ 'mentor-stage-b': exposed(EXPOSED_MODEL_TOOL), // mentor signals over mentee output
817
+ ResumeValidator: exposed(EXPOSED_TOOL), // matches a resume UUID against topic/session state
818
+
819
+ // ── Jobs ──
820
+ PipeSessionSpawner: exposed(EXPOSED_USER_TOOL), // spawns from (possibly user-authored) task descriptions
821
+ CartographerSweep: exposed(EXPOSED_TOOL), // authors summaries over untrusted CODE
822
+ StandardsCoverageEnrichment: exposed(EXPOSED_TOOL),
823
+
824
+ // ── Argued NOT-EXPOSED (pinned shrink-only) — no live LLM callsite carrying untrusted content.
825
+ // This set mirrors LLM_UNTRUSTED_INPUT's argued-false set exactly (cross-checked by the ratchet). ──
826
+ PromiseBeacon: notExposed(
827
+ 'no live LLM prompt — generateStatusLine/classifyProgress hooks are unwired at the construction site; no untrusted content can enter (matches its untrustedInput/bench-coverage exemption)',
828
+ ),
829
+ InteractivePoolCanaryJudge: notExposed(
830
+ 'judges a FIXED known-answer canary probe — the input is a constant, not external content; a planted instruction cannot reach it',
831
+ ),
832
+ AutoApprover: notExposed(
833
+ 'mechanical key injection + audit logging, no LLM prompt of its own; the upstream untrusted-judging callsite is InputClassifier.classify()',
834
+ ),
835
+ IntegrationGate: notExposed(
836
+ 'no LLM prompt of its own — delegates to JobReflector.reflect(); zero LLM-provider callsites of its own that see untrusted content',
837
+ ),
838
+ CoherenceGate: notExposed(
839
+ 'no callsite carries attribution CoherenceGate — all LLM calls flow through CoherenceReviewer.callApi(), classified exposed there',
840
+ ),
841
+ InputDetector: notExposed(
842
+ 'attribution-manifest alias only (a legacy prompt-pattern matcher); the live matcher calls with attribution PromptGate, classified exposed there',
843
+ ),
844
+ };
845
+
846
+ /**
847
+ * FD5b — resolve a component's STATIC injection exposure (fail-safe). Strips a
848
+ * trailing "/segment" operation suffix + a leading "server:" prefix (mirroring
849
+ * componentCategories.categoryForComponent) then looks the base up in the
850
+ * exhaustive map. A missing/unknown component resolves EXPOSED (fail-closed): the
851
+ * safe direction — an unclassified call is treated as if it could carry an
852
+ * injection, so it is never routed onto a non-injection-safe door. Pure.
853
+ */
854
+ export function resolveInjectionExposure(component: string | undefined): boolean {
855
+ if (!component) return true; // fail-closed: no attribution ⇒ assume exposed
856
+ const base = component.split('/')[0].replace(/^server:/, '').trim();
857
+ const row = LLM_ROUTING_INJECTION_EXPOSURE[base];
858
+ if (!row) return true; // unknown component ⇒ exposed (fail-safe skip)
859
+ return row.exposed;
860
+ }
861
+
688
862
  /* ────────────────────────────────────────────────────────────────────────────
689
863
  * S4 Increment A2 — nature-axis routing: door taxonomy, label registry, chains.
690
864
  *
@@ -0,0 +1,28 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Builds FD5b of the (still-dark) nature-axis router (spec: docs/specs/nature-axis-routing.md): the injection-exposure classification and the route gate that keeps an injection-exposed internal check off a non-injection-safe model door.
9
+
10
+ - **`LLM_ROUTING_INJECTION_EXPOSURE`** (src/data/llmBenchCoverage.ts): a new exhaustive static map — one row per known LLM component, each declaring `exposed` (defaults true / fail-safe) plus an input-shape declaration (can user / model / tool content enter). A component is `exposed:false` only when explicitly audited as carrying no untrusted content, with a written reason. `resolveInjectionExposure()` returns EXPOSED for any unknown component (fail-closed).
11
+ - **The route gate** (src/core/IntelligenceRouter.ts): resolveRoute now skips any door marked `injectionSafe:false` when the component is exposed. A tightening-only per-call `attribution.injectionExposed` flag can raise exposure but can never relax a statically-exposed component.
12
+ - A new ratchet test enforces the map is exhaustive over the component registry (both directions), fail-safe, cross-checked against the reviewed untrusted-input classification, and pins the input-classifier components as exposed. A new resolver-test block covers the gate and the fresh-per-call injection-cache isolation.
13
+
14
+ NO-OP in this increment: the only non-injection door in the shipped chains is also a metered door, which is already skipped until the later paid-door increment — so over the real chains the gate changes nothing. Dev-gated / dark: the whole nature block runs only when sessions.natureRouting is enabled; unset/off is byte-identical to before (asserted). This is the classification + gate only — NOT the metered-door Increment B, and NOT the go-live flip.
15
+
16
+ ## What to Tell Your User
17
+
18
+ This is internal plumbing for how I choose which model runs my own background checks — nothing to turn on, and nothing about our conversations changes. In plain terms: I gave each of my internal checks a label saying whether it reads content that could contain hidden instructions from an outside source, and I added a rule so a check that does can never be sent to a cheaper model that a test showed is easier to trick. The routing feature is still off by default, so today this is invisible — it just means the unsafe path is sealed shut in advance, and it defaults to the cautious choice whenever it isn't sure.
19
+
20
+ ## Summary of New Capabilities
21
+
22
+ - **Injection-exposure map**: a build-enforced, exhaustive table classifying each internal AI check by whether it handles untrusted content, defaulting to the cautious "exposed" when unsure.
23
+ - **Route injection gate**: the model router refuses to place an untrusted-content check onto a model door proven easier to fool, evaluated fresh on every call.
24
+
25
+ ## Evidence
26
+
27
+ - tests/unit/nature-routing-injection-exposure-ratchet.test.ts (11 tests) and the new FD5b block in tests/unit/nature-routing-resolver.test.ts — green, covering exhaustiveness both directions, fail-safe resolve, the pinned not-exposed set, input-shape coherence, the untrusted-input cross-check, R8, the gate (exposed skips / trusted keeps / critical-gate fail-closed), and injection-cache isolation.
28
+ - `npx tsc --noEmit` clean; the FD4 nature-chains build-lint clean; the sibling untrusted-input / bench-coverage / routing-nature ratchets green; the pre-existing byte-identical-when-off block and A1's degrade-clamp assertion untouched and green.
@@ -0,0 +1,47 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Added the observe-only ("shadow") phase of the Turn-End Self-Deferral Guard: a meaning-based judge on
9
+ the turn-end surface that records when a turn-ending message hands the operator a decision about work
10
+ the agent could do within its own means (the "you deferred again" pattern that slipped every existing
11
+ guard on 2026-07-04). It folds one new allow-class rule (`U_SELF_DEFERRAL`) plus four fields into the
12
+ SINGLE existing `UnjustifiedStopGate` turn-end call — no new LLM call, no new hook, no new route. The
13
+ judge receives the recent user turns (a bounded, fail-open transcript tail-read) so it can distinguish a
14
+ genuine operator decision from a self-deferral. Verdicts are recorded to the existing `StopGateDb`
15
+ (new columns + a real `ALTER TABLE` migration and age-based retention, both of which did not previously
16
+ exist). It BLOCKS NOTHING — it only records, to produce the telemetry needed to later decide whether the
17
+ blocking phase (deferred) is worth building. Dev-agent-gated (on for a development agent, dark on the
18
+ fleet).
19
+
20
+ ## What to Tell Your User
21
+
22
+ Nothing proactive — this is an internal, observe-only telemetry feature that changes no behavior. If a
23
+ user asks whether the agent has a check for handing work back to them that it could do itself, the
24
+ answer is: yes, a quiet watch-only version now records those moments so we can measure how often it
25
+ happens and how accurately it is detected, before ever deciding whether the agent should actively step
26
+ in. It never blocks or delays a message.
27
+
28
+ ## Summary of New Capabilities
29
+
30
+ - A turn-end judge that classifies self-deferral (agent hands the operator agent-ownable work) and
31
+ records it, with recent-user-turn context, on every finished turn.
32
+ - Recording rides the existing stop-gate call (zero new LLM calls); it blocks nothing and changes no
33
+ message.
34
+ - New `StopGateDb` columns with a built idempotent column migration (existing agents gain the columns on
35
+ update) and real age-based retention.
36
+ - Dev-agent gated (`monitoring.selfDeferralGuard`); off-state is byte-for-byte the prior behavior.
37
+ - The enforce/blocking phase is deliberately DEFERRED behind separately-gated preconditions (spec §10).
38
+
39
+ ## Evidence
40
+
41
+ - Spec: `docs/specs/turn-end-self-deferral-guard.md` (v4, Phase A CONVERGED — 4 review rounds; operator
42
+ approved the observe-only build, topic 29836, 2026-07-05).
43
+ - 55 tests across 5 new suites + updated rule-count assertions: `tests/unit/self-deferral-guard.test.ts`
44
+ (14), `tests/unit/stopGateDb-self-deferral-migration.test.ts` (6),
45
+ `tests/unit/stopGateTranscriptTail.test.ts` (13), `tests/integration/self-deferral-guard-route.test.ts`
46
+ (2), `tests/e2e/self-deferral-guard-feature-alive.test.ts` (2), `tests/unit/UnjustifiedStopGate.test.ts`
47
+ (regression). `tsc --noEmit` clean.
@@ -0,0 +1,62 @@
1
+ # Side-Effects Review — FD5b: injection-exposure static map + route-resolution injection gate
2
+
3
+ **Version / slug:** `nature-axis-routing-fd5b-injection-exposure`
4
+ **Date:** `2026-07-05`
5
+ **Author:** `echo (build hand, topic 29723 routing follow-through)`
6
+ **Second-pass reviewer:** `not required (Tier 1 — deterministic static classification + a pure candidate-eligibility filter, dark/dev-gated, byte-identical no-op in Increment A; same surface + precedent as A2.1 FD4-lints #1388)`
7
+
8
+ ## Summary of the change
9
+
10
+ Builds FD5b of `docs/specs/nature-axis-routing.md` (§283-294): the exhaustive, fail-safe **injection-exposure** classification the nature router consults, plus the resolve-time gate that keeps an injection-exposed component off a non-injection-safe door. Files touched:
11
+
12
+ - `src/data/llmBenchCoverage.ts` — new `LLM_ROUTING_INJECTION_EXPOSURE` map (exhaustive over `COMPONENT_CATEGORY`, `exposed` defaults true / fail-safe, each row carries the spec §371 input-shape declaration), `InjectionExposure`/`InjectionInputShape` types, and the pure `resolveInjectionExposure(component)` fail-closed resolver.
13
+ - `src/core/IntelligenceRouter.ts` — a new `isInjectionExposed` dep threaded into `resolveRoute`'s availability walk (skips a `injectionSafe:false` position when the component is exposed, reason `injectionUnsafe`), and the exported `isComponentInjectionExposed(component, perCallExposed?)` composer (static OR tighten-only per-call flag), wired at the class caller.
14
+ - `src/core/types.ts` — an OPT-IN, tightening-only `attribution.injectionExposed?: boolean`.
15
+ - Tests — NEW `tests/unit/nature-routing-injection-exposure-ratchet.test.ts` (exhaustiveness both directions, fail-safe resolve, pinned NOT-EXPOSED set + reasons, input-shape coherence, cross-check vs `LLM_UNTRUSTED_INPUT`, R8 pin) and a NEW FD5b block appended to `tests/unit/nature-routing-resolver.test.ts` (the gate + injection-cache isolation). The pre-existing byte-identical block (`nature-routing-resolver.test.ts:261-296`) and A1's clamp assertion (`:161-162`) are UNTOUCHED and green.
16
+
17
+ ## Decision-point inventory
18
+
19
+ - `FD5b injection gate (resolveRoute availability walk)` — add — a PURE candidate-eligibility filter: an injection-exposed component skips a `injectionSafe:false` door. NO-OP in Increment A (the only such door, `groq-api`, is also metered → already skipped one line above).
20
+ - `LLM_ROUTING_INJECTION_EXPOSURE static classification` — add — build-time DATA (a signal), ratchet-enforced exhaustive + fail-safe; the router consults it, it holds no runtime authority of its own.
21
+ - `attribution.injectionExposed per-call flag` — add — tightening-only; can raise exposure, can NEVER relax a statically-exposed component.
22
+ - `sessions.natureRouting enable/dryRun gate` — pass-through — the whole nature block (and therefore the injection gate) still runs ONLY when enabled.
23
+
24
+ ## 1. Over-block
25
+
26
+ **What legitimate inputs does this reject that it shouldn't?** In Increment A: none observable. The gate only removes a `injectionSafe:false` position for an exposed component, and the sole such position in the shipped chains (`groq-api/gpt-oss-120B`) is already unconditionally skipped as a metered door — so the resolved route is byte-identical (asserted: "the real default chains are UNAFFECTED"). The fail-safe default (unknown → exposed) could in principle skip a non-injection door for an unclassified component, but the exhaustiveness ratchet guarantees every real component is classified, so "unknown" never occurs for a shipped callsite; the fail-safe only governs a genuinely unregistered label, where skipping the non-injection door is the intended safe direction.
27
+
28
+ ## 2. Under-block
29
+
30
+ **What failure modes does this still miss?** The classification is static build-time judgment — a callsite whose prompt SILENTLY starts carrying untrusted content while its row stays `exposed:false` is a semantic-drift miss. FD5b mitigates this TODAY via the ratchet cross-check against `LLM_UNTRUSTED_INPUT` (a component that becomes `untrustedInput:true` must also become `exposed:true`, or CI fails) and the input-shape coherence invariant. The full FD7 semantic-drift guard — the prompt-anchor fingerprint LINT (spec §376-384) that re-touches a row when its prompt source changes — is a SEPARATE increment the spec itself defers ("deferred to its own A/B-gated increments"); it is NOT built here. The FD4.2 R-rule lints (R4/R5/R7 injection-exposed-JUDGE bans, R8 Flash-Lite pin as a LINT) are the next increment and are not built here either.
31
+
32
+ ## 3. Level-of-abstraction fit
33
+
34
+ Correct altitude. The classification lives beside its sibling axes (`LLM_UNTRUSTED_INPUT`, `LLM_ROUTING_NATURE`) in the same data module and rides the same exhaustive-ratchet pattern. The gate is a pure predicate consulted by the existing `resolveRoute` fold (the spec's own framing: "the safety/injection/allowlist/R-rule checks are candidate-eligibility FILTERS … each a pure predicate"), injected as a dep so exposure is evaluated fresh per call — not a new engine, not a new config surface. A smarter gate does not already own this; injection-exposure is intrinsic to routing and has no other home.
35
+
36
+ ## 4. Signal vs authority compliance
37
+
38
+ Compliant. The injection-exposure map is deterministic build-time DATA (a signal, like the nature map), enforced by an exhaustive ratchet — it is not a brittle runtime check with blocking authority. The gate DOES exercise authority (it removes a candidate door), but only ever in the SAFE direction (toward a more-careful door / fail-closed for a critical gate), on a MODEL-ROUTING decision — it never blocks a user message, never reads or credits a principal identity, never grants anything. The per-call flag can only tighten, never relax, so a forgotten callsite cannot open a non-injection door. `docs/signal-vs-authority.md` satisfied.
39
+
40
+ ## 5. Interactions
41
+
42
+ Interacts with `resolveRoute` (one skip predicate in the availability loop, placed AFTER the metered skip so the metered `groq-api` is already excluded → guaranteed no-op) and the class `evaluate()` caller (composes the per-call tighten). It does NOT shadow the FD4 harness-door clamp (orthogonal: FD4 governs the claude-code door, FD5b governs `injectionSafe:false` doors) and does not double-fire. Existing resolver tests (all exposed:true real components on default chains) are unaffected because no non-metered default door is a non-injection door. The A1 `clampClaudeCliSwapModel` degrade clamp is untouched (its assertion stays green).
43
+
44
+ ## 6. External surfaces
45
+
46
+ No external surface. No new HTTP route, CLI command, MCP tool, Telegram/Slack path, or network egress. `attribution.injectionExposed` is an additive optional field — every existing caller is byte-identical (omitted ⇒ static map authoritative).
47
+
48
+ ## 6b. Operator-surface quality
49
+
50
+ No operator-facing surface added or changed. The `injectionUnsafe` skip reason is an internal code comment/marker for the future FD5 structured-reason-code surface; nothing is emitted to a user yet. No secrets, tokens, or file paths surface anywhere.
51
+
52
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
53
+
54
+ Machine-local by design, and correctly so. The injection-exposure map is static source data compiled into every machine identically; `resolveInjectionExposure` is a pure per-call function over that data with no persisted or replicated state. Each machine resolves the identical verdict for the same component independently — there is no cross-machine state to strand, replicate, or coalesce. A per-call `attribution.injectionExposed` is scoped to the single call on the machine serving it. No lease, ledger, or notice interaction.
55
+
56
+ ## 8. Rollback cost
57
+
58
+ Low and clean. Revert the PR: the map + resolver + gate predicate + the optional type field drop out; `resolveRoute` returns to the pre-FD5b availability walk (which, in Increment A, produced identical routes). No migration, no persisted state, no config schema change, no data-format change. Because the gate only runs when `natureRouting.enabled` AND is a no-op over the shipped chains, a rollback is invisible to every fleet agent (feature dark) and to any dev agent in dryRun.
59
+
60
+ ## Conclusion
61
+
62
+ FD5b ships the fail-safe injection-exposure classification and the resolve-time gate that structurally seals an injection-exposed call off a non-injection door — a no-op in Increment A (the one non-injection door is also metered), byte-identical when the feature is off, and dark/dev-gated. The classification is exhaustive and cross-checked against the reviewed `LLM_UNTRUSTED_INPUT` axis so it cannot silently diverge; the prompt-anchor fingerprint LINT (FD7 semantic-drift) and the FD4.2 R-rule lints are the tracked next increments, not built here.
@@ -0,0 +1,48 @@
1
+ # ELI16 — a quiet watcher that notices when I try to hand you back work I could do myself
2
+
3
+ ## The problem this fixes
4
+
5
+ There's a specific way I let you down that nothing was catching. At the end of a turn I'd sometimes
6
+ write something like "I've stopped here — want me to line that up, or would you rather steer me
7
+ elsewhere?" — handing YOU a decision about work that I could actually just do myself. It sounds polite
8
+ and reasonable, so none of the existing guards flagged it: it wasn't a routed message, it didn't match
9
+ any banned phrase, and it ended with a real-sounding question that the meaning-based rules are told to
10
+ let through. The one check smart enough to catch it (a meaning-based judge) wasn't watching the
11
+ turn-end surface at all, and outside an autonomous run there was no always-on "could you do this
12
+ yourself?" check anywhere.
13
+
14
+ ## What this change adds (and, just as important, what it does NOT do)
15
+
16
+ It puts a meaning-based judge on the turn-end surface that, on every finished turn, quietly asks: "did
17
+ this message hand the operator a decision about work the agent could do within its own means?" It reads
18
+ a little recent conversation (your last few messages) so it can tell a genuine "your call" (taste,
19
+ priority, a credential only you have) apart from me punting work I should just do. It writes down what
20
+ it saw.
21
+
22
+ Crucially, in this phase it **blocks nothing**. It never stops a message, never delays a turn, never
23
+ changes what I say. It only records — so we can measure how often it fires and how accurate it is
24
+ before ever deciding whether to let it actively intervene. That "actively stop me" version is a
25
+ separate, deliberately-deferred piece of work with its own hard design questions (chiefly: how to make
26
+ it impossible for me to quietly switch off or game, and how to guarantee it never wrongly blocks you).
27
+
28
+ ## Why it's safe
29
+
30
+ - **It cannot wedge a turn.** It rides the single check that already runs at every turn-end (no new
31
+ model call, no second judge), it only adds fields on the "allow" path, and it never touches the
32
+ blocking logic. Every message still ends exactly as it would have.
33
+ - **The context read can't hang me.** Reading your recent turns is a bounded, fail-open tail-read: it
34
+ scans at most a small slice from the end of the transcript, caps each turn's length, and on anything
35
+ weird (missing file, giant line, bad data) it simply proceeds with no context instead of erroring.
36
+ - **It's off on the fleet by default.** It only turns on for a development agent (me), so it soaks on
37
+ one agent first. When off, nothing is recorded and the base behavior is byte-for-byte unchanged —
38
+ I even proved the shared judge prompt is untouched when the feature is off.
39
+ - **It's reversible.** No blocking, and the only new state is extra columns in an existing local
40
+ telemetry database (with a real prune so it can't grow forever) — turning the flag off stops it cold.
41
+
42
+ ## How we know it works
43
+
44
+ Fifty-five tests across five new suites: the classifier catches a real self-deferral and correctly lets
45
+ a genuine "your call" through; the tricky case (a design-question shape that's actually me punting
46
+ doable work) resolves the right way; the existing rules' verdicts are proven unchanged by the prompt
47
+ edit; the database migration adds its columns idempotently on an old database and prunes old rows; and
48
+ the transcript reader stays bounded and never throws on bad input.