instar 1.3.725 → 1.3.727

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 (37) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +45 -4
  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 +1 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  8. package/dist/core/PostUpdateMigrator.js +19 -0
  9. package/dist/core/PostUpdateMigrator.js.map +1 -1
  10. package/dist/core/types.d.ts +4 -0
  11. package/dist/core/types.d.ts.map +1 -1
  12. package/dist/core/types.js.map +1 -1
  13. package/dist/messaging/slack/SlackAdapter.d.ts +15 -0
  14. package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
  15. package/dist/messaging/slack/SlackAdapter.js +20 -2
  16. package/dist/messaging/slack/SlackAdapter.js.map +1 -1
  17. package/dist/messaging/slack/types.d.ts +38 -0
  18. package/dist/messaging/slack/types.d.ts.map +1 -1
  19. package/dist/messaging/slack/types.js +4 -0
  20. package/dist/messaging/slack/types.js.map +1 -1
  21. package/dist/monitoring/AgentWorktreeReaper.d.ts +12 -0
  22. package/dist/monitoring/AgentWorktreeReaper.d.ts.map +1 -1
  23. package/dist/monitoring/AgentWorktreeReaper.js +19 -0
  24. package/dist/monitoring/AgentWorktreeReaper.js.map +1 -1
  25. package/dist/permissions/TestWorkspacePrincipalSource.d.ts +133 -0
  26. package/dist/permissions/TestWorkspacePrincipalSource.d.ts.map +1 -0
  27. package/dist/permissions/TestWorkspacePrincipalSource.js +189 -0
  28. package/dist/permissions/TestWorkspacePrincipalSource.js.map +1 -0
  29. package/dist/permissions/index.d.ts +1 -0
  30. package/dist/permissions/index.d.ts.map +1 -1
  31. package/dist/permissions/index.js +1 -0
  32. package/dist/permissions/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/data/builtin-manifest.json +19 -19
  35. package/upgrades/1.3.727.md +143 -0
  36. package/upgrades/side-effects/reaper-initial-pass.md +103 -0
  37. package/upgrades/side-effects/slack-test-workspace-principal-source.md +227 -0
@@ -0,0 +1,143 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ `AgentWorktreeReaper.start()` scheduled ONLY a 24h `setInterval` — no initial
9
+ pass. Agent servers restart far more often than daily (auto-updates, sleep/wake
10
+ supervisor bounces), so the interval timer reset on every restart and an
11
+ **enabled + armed** reaper never ran a single pass. Measured live on 2026-07-02:
12
+ 86 worktrees / 25GB accumulated on a machine with `enabled: true, dryRun: false`
13
+ and `lastPassAt: 0` — the feature was on and structurally inert (root cause of
14
+ that day's fseventsd/reboot resource incident).
15
+
16
+ The fix:
17
+
18
+ - New `initialPassDelayMs` config (default 15 min; `<= 0` disables — the exact
19
+ legacy behavior as a no-release rollback lever) on
20
+ `monitoring.agentWorktreeReaper`.
21
+ - `start()` now schedules a ONE-TIME initial pass after that delay (unref'd
22
+ timer, cleared by `stop()`), then the unchanged 24h cadence. The delay keeps
23
+ the pass off the busy post-boot window.
24
+ - The pass runs through the same `reap()` as always: same KEEP gates (in-use /
25
+ dirty / unmerged / detached), same dry-run gating, same `maxReapsPerPass`
26
+ blast-radius cap, same per-path reclaim-failure breaker. Nothing about WHAT
27
+ may be deleted changed — only WHEN passes run.
28
+ - `GET /worktrees/agent-reaper` snapshot additively reports
29
+ `initialPassPending`.
30
+ - CLAUDE.md template + PostUpdateMigrator addendum so deployed agents learn the
31
+ new behavior (idempotent, content-sniffed on `initialPassDelayMs`).
32
+
33
+ Fixes the roadmap-0.3 blocker found in the Slack permission-gate false-positive
34
+ review (`docs/audits/slack-permission-fp-review-2026-07.md`, row 29): after the
35
+ 2026-07-01 silent-loss registry rebuild, the Slack live-test scenario cast could no
36
+ longer resolve, so every Slack sender — including the workspace owner — resolved as an
37
+ unregistered guest. Root cause: the gate derives roles from the production user registry
38
+ (`users.json`), the rebuild removed the five Slack test-cast principals, and the
39
+ fixture-identity guard (correctly) refuses to let test identities back into the
40
+ production registry.
41
+
42
+ The fix is a SEPARATE, test-workspace-scoped principal source rather than a registry
43
+ edit:
44
+
45
+ - **New `TestWorkspacePrincipalSource` + `ChainedUserLookup`** (`src/permissions/`).
46
+ The gate's `SlackPrincipalResolver` now reads production-registry-FIRST, then the
47
+ cast as a fallback. A production-registered user can never be shadowed or
48
+ role-escalated by a cast entry.
49
+ - **Config-carried cast** — `permissionGate.testCast` in the Slack messaging config
50
+ (`{ testWorkspace, workspaceId, principals[] }`). It lives beside the workspace
51
+ tokens, so a `users.json` rebuild can never silently drop it again.
52
+ - **Code-enforced workspace scoping** — the cast resolves ONLY while the adapter's
53
+ VERIFIED connected team id (captured from Slack's own `auth.test` at start, exposed
54
+ via `SlackAdapter.getConnectedTeamId()`) EXACTLY equals `workspaceId`. Any other
55
+ workspace → the source is structurally invisible (production behavior byte-identical
56
+ to no cast). An unlisted uid in the test workspace → today's unregistered-guest default.
57
+ - **Partition invariant** — every cast `slackUserId` must match the SAME fixture-marker
58
+ matcher (`users/testIdentityMarkers.ts`) the production guard uses to refuse fixtures;
59
+ a non-fixture id is refused at load. One identity, two disjoint homes.
60
+ - **Fail-closed self-declaration** — the block must set `testWorkspace: true`; without
61
+ it the whole cast is ignored (zero principals, one loud log line, no production
62
+ effect). A cast can never be activated by accident.
63
+ - **Authority scope (KYP)** — the source feeds permission-gate role resolution ONLY. It
64
+ takes no state dir and has no write surface, so it cannot create user-registry
65
+ entries, cannot feed operator binding, and cannot affect message authorization /
66
+ sender validation.
67
+
68
+ The runbook (`docs/specs/SLACK-ORG-TEST-WORKSPACE-RUNBOOK.md`) is patched: the old
69
+ "seed the cast in users.json" step is replaced with the `testCast` config block plus a
70
+ re-provision checklist so a registry rebuild can't silently lose principal resolution
71
+ again. Dark by default — nothing activates without an explicit `testCast` block.
72
+
73
+ ## What to Tell Your User
74
+
75
+ <!-- audience: user, maturity: stable -->
76
+
77
+ If you switched on the stale-worktree auto-cleaner, it now genuinely runs: one
78
+ cleaning pass about 15 minutes after each server start, then daily as designed.
79
+ Previously a quirk meant frequent server restarts kept pushing its first run
80
+ into the future forever, so leftover work folders could quietly pile up into
81
+ tens of gigabytes even with the cleaner enabled. Its safety rules are untouched:
82
+ it still only removes folders whose work is fully merged and saved, still does
83
+ dry-run first, and it stays entirely off unless you enabled it.
84
+
85
+ - **Slack permission demo is more robust**: the test harness that proves the Slack
86
+ "who's allowed to do what" feature now keeps its demo cast in a safe place that a
87
+ routine cleanup of your real people-list can't wipe out. This is behind-the-scenes
88
+ test infrastructure — nothing changes in how I work for you day to day, and the
89
+ pretend demo roles are locked to the throwaway test workspace so they can never leak
90
+ into a real one.
91
+
92
+ ## Summary of New Capabilities
93
+
94
+ - The stale-worktree reaper actually fires on real deployments: a one-time
95
+ cleaning pass ~15 minutes after boot (tunable/disableable via
96
+ `monitoring.agentWorktreeReaper.initialPassDelayMs`), unchanged daily cadence
97
+ thereafter, and `initialPassPending` visibility on the existing report route.
98
+
99
+ | Capability | How to Use |
100
+ |-----------|-----------|
101
+ | Test-workspace-scoped Slack principal cast | Add a `testCast` block (with `testWorkspace: true`, `workspaceId`, `principals`) under the Slack `permissionGate` config — dark unless configured |
102
+
103
+ ## Evidence
104
+
105
+ - `tests/unit/agent-worktree-reaper.test.ts`: 47/47 green — 7 new tests (initial
106
+ pass fires exactly once at the configured delay and before the interval;
107
+ respects dry-run; disabled reaper sets no timers; `<= 0` restores
108
+ interval-only; `stop()` cancels the pending pass; 24h cadence unchanged after
109
+ the initial pass; snapshot reports `initialPassPending` honestly).
110
+ - Live incident data (2026-07-02, topic 30379): reaper enabled+armed with 25
111
+ reap-eligible worktrees and `lastPassAt: 0`; server uptime history shows no
112
+ 24h-continuous window for weeks.
113
+
114
+ Root cause is a live datapoint captured in `docs/audits/slack-permission-fp-review-2026-07.md`:
115
+ row 29 (2026-07-02 18:15:19, the only organic decision row) shows the workspace OWNER
116
+ seat's Slack UID resolving as `role: guest, registered: false` — the same UID that
117
+ resolved as `role: owner, registered: true` in every June scenario row, because the
118
+ 2026-07-01 rebuild removed the cast from `users.json`.
119
+
120
+ The fix is verified by `tests/unit/slack-test-workspace-principal-source.test.ts` (22
121
+ tests, all passing), which exercises BOTH sides of every boundary:
122
+
123
+ - matching workspace + listed uid → registered role; matching workspace + unlisted uid
124
+ → guest.
125
+ - non-matching workspace + listed uid → source invisible, and the resolved principal is
126
+ asserted EQUAL to the no-cast baseline (the scoping proof that roles can't leak into a
127
+ production workspace).
128
+ - missing `testWorkspace: true` → source disabled, zero seats, loud log line, and
129
+ byte-identical to no cast.
130
+ - non-fixture uid refused at load; cast cap enforced; production-registry-first
131
+ precedence; a throwing source skipped.
132
+ - authority-scope assertions: the source exposes only the read contract (no
133
+ write/registry/operator methods) and takes no state dir.
134
+
135
+ `tests/integration/slack-testcast-principal-pipeline.test.ts` (5 tests, all passing)
136
+ additionally drives the EXACT server.ts composition through the real inbound chokepoint
137
+ (`SlackAdapter._handleMessage`) and asserts the durable decision-ledger rows: the owner
138
+ seat resolves `owner, registered:true` through the live path (the row-29 regression),
139
+ the same seat is an unregistered guest when connected to any other workspace or before
140
+ `auth.test` verifies the connection, an unlisted uid stays a guest, and a production
141
+ record beats the cast for the same uid.
142
+
143
+ `npx tsc --noEmit` exits 0.
@@ -0,0 +1,103 @@
1
+ # Side-Effects Review — AgentWorktreeReaper one-time initial pass (reaper-never-fires fix)
2
+
3
+ **Version / slug:** `reaper-initial-pass`
4
+ **Date:** `2026-07-02`
5
+ **Author:** `Instar Agent (echo)`
6
+ **Second-pass reviewer:** `echo reviewer subagent (independent read)`
7
+
8
+ ## Summary of the change
9
+
10
+ `AgentWorktreeReaper.start()` scheduled ONLY a 24h `setInterval` with no initial pass. Real agent servers restart far more often than daily (updates, sleep/wake supervisor bounces), so the interval reset forever and an **enabled + armed** reaper never ran a single pass — measured on 2026-07-02 as 86 worktrees / 25GB accumulated on a machine with `enabled: true, dryRun: false` (the fseventsd/reboot incident's root cause). The fix adds a one-time initial pass `initialPassDelayMs` (default 15 min) after `start()`, keeping the 24h cadence unchanged. Files: `src/monitoring/AgentWorktreeReaper.ts` (config field + initial `setTimeout`, unref'd, cleared by `stop()`, `initialPassPending` in `snapshot()`), `src/core/types.ts` + `src/config/ConfigDefaults.ts` (config plumbing), `src/core/PostUpdateMigrator.ts` (CLAUDE.md awareness bullet for fresh + already-installed sections), `tests/unit/agent-worktree-reaper.test.ts` (7 new tests).
11
+
12
+ ## Decision-point inventory
13
+
14
+ - `AgentWorktreeReaper.evaluate()` (keep/reap classifier) — **pass-through** — untouched; all safety gates (in-use / dirty / unmerged / detached → KEEP) unchanged.
15
+ - `AgentWorktreeReaper.start()` scheduling — **modify** — adds WHEN a pass runs (one initial pass), never WHAT a pass may delete.
16
+ - `killsEnabled` (enabled && !dryRun) — **pass-through** — the initial pass runs through the same `reap()` with the same dry-run/enabled gating and the same `maxReapsPerPass` blast-radius cap and per-path failure breaker.
17
+
18
+ ---
19
+
20
+ ## 1. Over-block
21
+
22
+ No block/allow surface — over-block not applicable. (Nothing new is rejected; the classifier is untouched.)
23
+
24
+ ---
25
+
26
+ ## 2. Under-block
27
+
28
+ The inverse risk (over-DELETE) is the relevant frame for a deleter: the initial pass cannot delete anything the interval pass wouldn't — same `reap()`, same gates, same cap. Residual miss: an agent whose server restarts more often than every `initialPassDelayMs` (15 min) still never completes a pass; that pathological restart-loop case is out of scope here (the crash-loop itself is the incident to fix) and the pass is cheap enough that a partial-uptime server still benefits on the next calm boot.
29
+
30
+ ---
31
+
32
+ ## 3. Level-of-abstraction fit
33
+
34
+ Right layer: the defect is purely in the component's own scheduling, so the fix lives in the component's `start()`. Alternatives considered and rejected: a scheduler job wrapping the reaper (duplicates an existing component lifecycle for no gain); persisting `lastPassAt` across restarts and firing when overdue (more state, more failure modes — the 15-min delayed pass achieves the same outcome stateless).
35
+
36
+ ---
37
+
38
+ ## 4. Signal vs authority compliance
39
+
40
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
41
+
42
+ - [x] No — this change has no block/allow surface.
43
+
44
+ The change adds no authority and no detector. The reaper's existing (deliberately conservative, fail-closed) classifier keeps sole authority over deletions; this change only makes the already-shipped schedule actually execute. Dry-run/enabled defaults are unchanged (ships OFF + dry-run).
45
+
46
+ ---
47
+
48
+ ## 5. Interactions
49
+
50
+ - **Shadowing:** none — no other component schedules reaper passes.
51
+ - **Double-fire:** initial pass and interval pass could theoretically overlap on a pathologically slow pass; `reap()` already has a `running` re-entrancy guard (returns empty, does not queue). Confirmed in code and covered by existing tests.
52
+ - **Races:** the initial pass lands ~15 min after boot — after the busy post-boot window, and any worktree in use at that moment is kept by the `isInUse` gate (lock or live process cwd). A concurrently-created fresh worktree is kept by `isInUse`/dirty/unmerged gates exactly as during an interval pass.
53
+ - **Feedback loops:** none — reaping does not feed anything that schedules reaping.
54
+
55
+ ---
56
+
57
+ ## 6. External surfaces
58
+
59
+ - Other agents on the same machine: each agent's server reaps only its own `.worktrees/` estate (worktreesDir-bounded) — unchanged.
60
+ - Install base: behavior change is that an agent with the reaper ENABLED will now actually reclaim merged+clean+idle worktrees within ~15 min of boot instead of never. The feature still ships OFF + dry-run by default, so fleet agents see no change until an operator opts in.
61
+ - External systems: the initial pass may trigger the existing one-per-sweep `gh` merged-PR call ~15 min after boot (same call the interval pass makes; fail-safe to cherry-only).
62
+ - Persistent state: none added; `initialPassPending` is in-memory observability on the existing snapshot route.
63
+ - **Operator surface (Mobile-Complete Operator Actions):** no operator-facing actions added — config-only tuning, and the existing `GET /worktrees/agent-reaper` report is unchanged in shape (one additive field).
64
+
65
+ ---
66
+
67
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
68
+
69
+ No operator surface — not applicable (no dashboard/approval/form files touched; one additive JSON field on an existing observability route).
70
+
71
+ ---
72
+
73
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
74
+
75
+ **Machine-local BY DESIGN.** A worktree estate is a per-machine filesystem artifact; each machine's reaper reclaims only its own disk. No user-facing notices are emitted (housekeeping events go to the existing reaper event listeners/logs), no durable state is held that could strand on topic transfer, and no URLs are generated. The pool-wide "how much disk is reclaimable across machines?" question remains answerable per-machine via the existing route (a merged view is a separate feature, not regressed here).
76
+
77
+ ---
78
+
79
+ ## 8. Rollback cost
80
+
81
+ Pure code change. Config kill-switch without a release: `{"monitoring": {"agentWorktreeReaper": {"initialPassDelayMs": 0}}}` restores exact legacy scheduling (interval-only). Full rollback: revert the PR, ship next patch. No persistent state, no migrations, no user-visible regression during the rollback window (the feature ships dark; worst case is returning to "reaper never fires", which is the status quo ante).
82
+
83
+ ---
84
+
85
+ ## Conclusion
86
+
87
+ The review confirms this is a scheduling-only fix to a real, measured never-fires defect, with the deletion authority, safety gates, blast-radius cap, failure breaker, and OFF+dry-run defaults all untouched. The one design question the review surfaced — could the initial pass fire during a busy/fragile boot window — is addressed by the 15-min delay, the unref'd timer (never holds the process open), the `running` re-entrancy guard, and the existing in-use/dirty KEEP gates. Clear to ship.
88
+
89
+ ---
90
+
91
+ ## Second-pass review (if required)
92
+
93
+ **Reviewer:** echo reviewer subagent (independent audit, 2026-07-02)
94
+ **Independent read of the artifact: concur**
95
+
96
+ Concur with the review. Verified against the real diff: (a) the initial pass invokes the identical `reap()` — same KEEP gates, same `killsEnabled` dry-run gate, same `maxReapsPerPass` cap, same per-path breaker, so it cannot delete anything the interval pass could not; (b) the `running` re-entrancy guard returns empty without queuing; (c) double-`start()` is blocked by the `this.timer` guard, `stop()` clears both timers, the fired callback self-clears so `snapshot()` stays honest, and production has exactly one `start()` callsite; (d) the migrator addendum is idempotent via the `initialPassDelayMs` content-sniff, the anchor matches deployed CLAUDE.md byte-for-byte (verified against a live deployment), and anchor drift degrades to an append — no corruption path; (e) `initialPassPending` is additive and backward-compatible at the single `res.json(snapshot())` consumer; (f) config plumbing delivers `initialPassDelayMs` from `.instar/config.json` with no server.ts change, and `0` genuinely restores interval-only scheduling. Reproduced the test evidence: 47/47 green.
97
+
98
+ ---
99
+
100
+ ## Evidence pointers
101
+
102
+ - `tests/unit/agent-worktree-reaper.test.ts` — 47/47 green (7 new: initial-pass timing, dry-run respected, disabled → no timers, `<=0` rollback lever, stop() cancels, 24h cadence unchanged, snapshot honesty).
103
+ - Live incident data: 2026-07-02 topic 30379 — reaper `enabled:true, dryRun:false, lastPassAt:0` with 25 reap-eligible worktrees sitting unreclaimed; server uptime histories show restarts always < 24h apart.
@@ -0,0 +1,227 @@
1
+ # Side-Effects Review — Slack test-workspace-scoped principal source
2
+
3
+ **Version / slug:** `slack-test-workspace-principal-source`
4
+ **Date:** `2026-07-02`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `echo (independent second pass)`
7
+
8
+ ## Summary of the change
9
+
10
+ Adds a sanctioned, workspace-scoped principal source for the Slack permission gate's
11
+ role resolution, so the live-test scenario cast can resolve WITHOUT being seeded into
12
+ the production user registry (`users.json`). Files touched:
13
+ `src/permissions/TestWorkspacePrincipalSource.ts` (new: `TestWorkspacePrincipalSource`
14
+ + `ChainedUserLookup`), `src/permissions/index.ts` (export), `src/messaging/slack/types.ts`
15
+ (`permissionGate.testCast` config shape incl. the `testWorkspace` marker),
16
+ `src/messaging/slack/SlackAdapter.ts` (capture + expose the VERIFIED connected team id
17
+ from `auth.test`), `src/commands/server.ts` (wire production-registry-first chained
18
+ resolution + fail-closed on the missing marker), the runbook
19
+ (`docs/specs/SLACK-ORG-TEST-WORKSPACE-RUNBOOK.md`), and the unit test suite. The single
20
+ decision point it interacts with is the gate's **principal role resolution** — it FEEDS
21
+ that lookup as a fallback data source; it adds no new block/allow authority.
22
+
23
+ ## Decision-point inventory
24
+
25
+ - `SlackPrincipalResolver` role resolution (`src/permissions/SlackPrincipalResolver.ts`) — **pass-through / feed** — the resolver now reads from a `ChainedUserLookup` (production registry first, then the test cast) instead of the bare production lookup. The resolver's own logic (registered vs guest, role derivation) is unchanged.
26
+ - `TestWorkspacePrincipalSource.resolveFromSlackUserId` — **add** — a read-only lookup that answers ONLY for the verified test workspace and ONLY for fixture-marker ids; every uncertainty resolves null (fail-closed to the resolver's existing unregistered-guest default).
27
+ - The permission gate's allow/refuse decision — **pass-through** — unchanged; it consumes the resolved principal exactly as before.
28
+
29
+ ---
30
+
31
+ ## 1. Over-block
32
+
33
+ **What legitimate inputs does this change reject that it shouldn't?**
34
+
35
+ No new block/allow surface — the source only resolves an id to a role (or null); the
36
+ gate still owns every allow/refuse decision. The only "rejection" it performs is at
37
+ LOAD time (refusing to admit a cast entry): a non-fixture id, an invalid role, a
38
+ duplicate, or an over-cap entry is dropped from the cast. Those refusals cannot
39
+ over-block a real user, because a refused cast entry simply falls through to production
40
+ resolution (which is the authoritative path anyway). A production-registered user is
41
+ never affected — the production lookup is consulted first and wins.
42
+
43
+ ---
44
+
45
+ ## 2. Under-block
46
+
47
+ **What failure modes does this still miss?**
48
+
49
+ The source is deliberately narrow, so the "misses" are by design: it does not itself
50
+ enforce anything (observe-only gate is unchanged), and it does not attempt to detect a
51
+ misconfigured `workspaceId` (a `workspaceId` that never matches the connected team id
52
+ just means the cast stays permanently inert — the safe direction, surfaced by the boot
53
+ log's `admitted/refused` counts and the runbook's re-provision checklist). It also does
54
+ not cover a cast id that a future edit removes from the fixture-marker list — such an id
55
+ would then be refused at load (`not-a-fixture-identity`), which is loud, not silent.
56
+
57
+ ---
58
+
59
+ ## 3. Level-of-abstraction fit
60
+
61
+ **Is this at the right layer?**
62
+
63
+ Yes — it is a low-level, in-memory DATA SOURCE (a `UserLookup` implementation) that feeds
64
+ the existing higher-level `SlackPrincipalResolver`, which in turn feeds the gate. It does
65
+ NOT re-implement role derivation (that stays in `deriveRole`), does NOT re-implement the
66
+ allow/refuse policy (that stays in `SlackPermissionGate`), and does NOT duplicate the
67
+ fixture-identity matcher (it reuses the single `matchesTestIdentityToken` the production
68
+ guard uses — one matcher, never two lists). The workspace-scope check is the one piece of
69
+ new logic, and it lives at exactly the layer that knows the verified connection (a supplier
70
+ reading `SlackAdapter.getConnectedTeamId()`).
71
+
72
+ ---
73
+
74
+ ## 4. Signal vs authority compliance
75
+
76
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
77
+
78
+ **Does this change hold blocking authority with brittle logic?**
79
+
80
+ - [x] No — this change produces a signal (a resolved role, or null) consumed by an existing smart gate; it holds no block/allow authority.
81
+
82
+ The source answers "what role, if any, does this id play in the sanctioned test
83
+ workspace?" and nothing more. It never blocks, never sends, never mutates. The
84
+ authoritative allow/refuse decision remains entirely with `SlackPermissionGate`. The
85
+ one gate-shaped decision it makes — "should I answer for this id at all?" — is a
86
+ structural OWNERSHIP check (verified connected team id EXACTLY equals the configured
87
+ workspace id), not a brittle content heuristic, and it fails CLOSED (null) on every
88
+ uncertainty. That is the signal-vs-authority-compliant shape: a cheap structural check
89
+ that only ever WITHHOLDS an answer, never grants an escalation.
90
+
91
+ ---
92
+
93
+ ## 5. Interactions
94
+
95
+ **Does this interact with existing checks, recovery paths, or infrastructure?**
96
+
97
+ - **Shadowing:** production resolution is consulted FIRST in the chain, so the cast can
98
+ never shadow a genuinely registered user. The reverse (cast shadowed by production) is
99
+ intended — a production record always wins. Verified by the `ChainedUserLookup`
100
+ precedence test.
101
+ - **Double-fire:** none — a single resolution per inbound message; `ChainedUserLookup`
102
+ returns at the first non-null source.
103
+ - **Races:** none — the cast is built once at wiring time and is immutable thereafter;
104
+ `resolveFromSlackUserId` is a pure map read behind the scope gate. No shared mutable
105
+ state, no concurrent writers.
106
+ - **Feedback loops:** none — the source is read-only and does not record or learn.
107
+ - **Fixture-identity guard:** complementary, not conflicting. The production guard refuses
108
+ fixtures INTO `users.json`; this source admits ONLY fixtures. Same matcher, disjoint
109
+ homes — an identity lives in exactly one store.
110
+
111
+ ---
112
+
113
+ ## 6. External surfaces
114
+
115
+ **Does this change anything visible outside the immediate code path?**
116
+
117
+ - Other agents on the same machine? No — the source is per-Slack-adapter, in-memory.
118
+ - Other users of the install base? No — dark by default; nothing loads unless a Slack
119
+ config adds a `testCast` block with `testWorkspace: true`. On a plain install the whole
120
+ path is inert.
121
+ - External systems? Reads one additional field (`team_id`) from the EXISTING `auth.test`
122
+ response the adapter already calls at startup. No new Slack API call, no contract change
123
+ (both changed adapter files carry a `CONTRACT-EVIDENCE: EXEMPT` marker with the reason).
124
+ - Persistent state? None — the source writes nothing (no state dir, no fs handle). It
125
+ cannot create/modify `users.json`, operator bindings, or any file. This is the load-
126
+ bearing authority-scope property (KYP): the cast feeds role resolution ONLY.
127
+ - Operator surface (Mobile-Complete Operator Actions)? No operator-facing actions — this is
128
+ test-harness wiring configured in `.instar/config.json`, not a runtime operator action.
129
+
130
+ ---
131
+
132
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
133
+
134
+ No operator surface — not applicable. This change touches no dashboard renderer, approval
135
+ page, or grant/revoke/secret-drop form.
136
+
137
+ ---
138
+
139
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
140
+
141
+ **Machine-local BY DESIGN.** A Slack adapter is bound to one workspace and runs on the
142
+ machine serving that Slack connection; the `testCast` config and the verified connected
143
+ team id are properties of THAT machine's live Slack socket. There is no cross-machine
144
+ state to replicate: the cast is read-only config-derived data, not learned/durable state,
145
+ so it neither strands on topic transfer nor needs a pool-wide merged read. It emits no
146
+ user-facing notices (no one-voice concern) and generates no URLs. If the same live-test
147
+ Slack adapter is ever run on two machines, each independently derives the same cast from
148
+ the same config and each independently verifies its own connected team id — no divergence
149
+ is possible because the input is static config plus a per-machine verified connection.
150
+
151
+ ---
152
+
153
+ ## 8. Rollback cost
154
+
155
+ **Pure code + config change — revert and ship a patch.** No persistent state is created,
156
+ so there is no data migration and no agent-state repair. Because the feature is dark by
157
+ default (nothing activates without an explicit `testCast` block), a rollback has zero
158
+ user-visible surface on any normal install. Removing the `testCast` block from the one
159
+ live-test config (or setting `enforce`/`observeOnly` off) instantly reverts to
160
+ production-registry-only resolution with no restart-order hazards beyond the normal
161
+ "restart to pick up config" rule.
162
+
163
+ ---
164
+
165
+ ## Conclusion
166
+
167
+ The review produced one substantive design fix during the build: the in-flight code
168
+ lacked the required `testWorkspace: true` self-declaration marker (requirement 3). It was
169
+ added as a fail-closed gate that lives IN the source constructor (not only at the wiring
170
+ site), so the "ignored + one loud log line, no production impact" guarantee holds for
171
+ every caller — Structure over Willpower. The scoping proof (why roles can't leak into a
172
+ production workspace) is the load-bearing property and is covered on both sides by tests:
173
+ matching-workspace resolves the cast; non-matching-workspace is byte-identical to having
174
+ no cast at all. The change is a signal-producing data source with no new authority, dark
175
+ by default, and is clear to ship.
176
+
177
+ ---
178
+
179
+ ## Second-pass review (if required)
180
+
181
+ **Reviewer:** echo (independent second pass — the change touches the word "gate")
182
+ **Independent read of the artifact: concur**
183
+
184
+ Independently re-derived the three safety properties and confirmed each is covered on both
185
+ sides in `tests/unit/slack-test-workspace-principal-source.test.ts`: (1) scope — matching
186
+ team + listed id resolves the role, non-matching team is invisible AND byte-identical to
187
+ the no-cast baseline; (2) partition — a non-fixture id is refused at load and the cap
188
+ bounds the source so it can't become a shadow registry; (3) opt-in — a missing
189
+ `testWorkspace` marker disables the whole source, loudly, and is byte-identical to no cast.
190
+ Also confirmed the authority-scope assertions: the source exposes only the `UserLookup`
191
+ read contract (no write/registry/operator methods) and takes no state dir, so it
192
+ structurally cannot write `users.json` or feed operator binding / sender validation. One
193
+ concern considered and dismissed: the scope check uses the adapter's connected team id
194
+ rather than a per-message `team_id` — but a Slack adapter is bound to exactly one workspace
195
+ (one bot token), so the verified connected team id IS authoritative for every inbound
196
+ message and is strictly stronger than trusting an envelope field. Concur with the review's
197
+ conclusion: clear to ship.
198
+
199
+ **Second-pass reviewer (dedicated subagent, 2026-07-02): CONCUR.** Independently
200
+ re-verified in code (not from this artifact): (a) no blocking authority — resolution
201
+ returns a record or null only, the gate keeps every allow/refuse; (b) fail-closed on
202
+ disabled/throw/unverified/mismatch; (c) zero changes under `src/users/` (`git diff`),
203
+ production fixture-identity guard fully intact; (d) production-first chain order at the
204
+ wiring site; (e) no fs/write surface, unreachable from sender auth (`isAuthorized` runs
205
+ BEFORE the observer and never consults a `UserLookup`); (f) both test suites re-run green;
206
+ (g) no-`testCast` installs byte-identical (absent/invalid/disabled block leaves the bare
207
+ production lookup; the whole wiring sits in try/catch degrading to production-only). Two
208
+ non-blocking notes, both addressed in this build: the Tier-2 integration suite is staged
209
+ with the commit, and `workspaceId` is now stored TRIMMED (a padded config value can no
210
+ longer yield a permanently-inert cast; covered by a new unit test).
211
+
212
+ ---
213
+
214
+ ## Evidence pointers
215
+
216
+ - `tests/unit/slack-test-workspace-principal-source.test.ts` — 23 tests, all passing (both
217
+ sides of scope, marker, partition, chain precedence, wiring, authority-scope, trim).
218
+ - `tests/integration/slack-testcast-principal-pipeline.test.ts` — 5 tests, all passing
219
+ (Tier 2: the EXACT server.ts composition driven through the real inbound chokepoint
220
+ `SlackAdapter._handleMessage`, asserted against the durable decision ledger — the
221
+ row-29 owner-resolves-owner proof, the cross-workspace invisibility proof, fail-closed
222
+ pre-verification, unlisted-uid guest default, and production-precedence-beats-cast).
223
+ Tier 3 (E2E route liveness) is not applicable: the feature adds no HTTP routes, and its
224
+ only wiring site (`server.ts` Slack messaging block) requires live Slack credentials.
225
+ - `npx tsc --noEmit` — exit 0.
226
+ - Root-cause datapoint: `docs/audits/slack-permission-fp-review-2026-07.md` row 29 (the
227
+ owner seat resolving `guest, registered:false` after the 2026-07-01 registry rebuild).