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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +45 -4
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +1 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +19 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/types.d.ts +4 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/slack/SlackAdapter.d.ts +15 -0
- package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
- package/dist/messaging/slack/SlackAdapter.js +20 -2
- package/dist/messaging/slack/SlackAdapter.js.map +1 -1
- package/dist/messaging/slack/types.d.ts +38 -0
- package/dist/messaging/slack/types.d.ts.map +1 -1
- package/dist/messaging/slack/types.js +4 -0
- package/dist/messaging/slack/types.js.map +1 -1
- package/dist/monitoring/AgentWorktreeReaper.d.ts +12 -0
- package/dist/monitoring/AgentWorktreeReaper.d.ts.map +1 -1
- package/dist/monitoring/AgentWorktreeReaper.js +19 -0
- package/dist/monitoring/AgentWorktreeReaper.js.map +1 -1
- package/dist/permissions/TestWorkspacePrincipalSource.d.ts +133 -0
- package/dist/permissions/TestWorkspacePrincipalSource.d.ts.map +1 -0
- package/dist/permissions/TestWorkspacePrincipalSource.js +189 -0
- package/dist/permissions/TestWorkspacePrincipalSource.js.map +1 -0
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +1 -0
- package/dist/permissions/index.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +19 -19
- package/upgrades/1.3.727.md +143 -0
- package/upgrades/side-effects/reaper-initial-pass.md +103 -0
- 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).
|