instar 1.1.2 → 1.1.4
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/README.md +270 -1
- package/dist/cli.js +26 -3
- package/dist/cli.js.map +1 -1
- package/dist/lifeline/ServerSupervisor.d.ts.map +1 -1
- package/dist/lifeline/ServerSupervisor.js +23 -1
- package/dist/lifeline/ServerSupervisor.js.map +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts +15 -0
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +107 -8
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/lifeline/rateLimitState.d.ts.map +1 -1
- package/dist/lifeline/rateLimitState.js +8 -1
- package/dist/lifeline/rateLimitState.js.map +1 -1
- package/dist/memory/NativeModuleHealer.d.ts.map +1 -1
- package/dist/memory/NativeModuleHealer.js +8 -1
- package/dist/memory/NativeModuleHealer.js.map +1 -1
- package/package.json +1 -1
- package/scripts/check-repo-invariants.mjs +68 -0
- package/scripts/pre-push-fixture-guard.mjs +155 -0
- package/src/data/builtin-manifest.json +32 -32
- package/upgrades/1.1.3.md +124 -0
- package/upgrades/1.1.4.md +42 -0
- package/upgrades/side-effects/lifeline-version-skew-recovery.md +180 -0
- package/upgrades/side-effects/test-env-isolation.md +84 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Side-Effects Review — Lifeline version-skew recovery
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `lifeline-version-skew-recovery`
|
|
4
|
+
**Date:** `2026-05-20`
|
|
5
|
+
**Author:** Echo (instar developer agent)
|
|
6
|
+
**Trigger:** 2026-05-19 → 2026-05-20 b2lead-insights field incident (21h silent
|
|
7
|
+
Telegram ingress drop after server auto-update from v1.0.13 → v1.1.0;
|
|
8
|
+
lifeline stayed on v1.0.13; HTTP 426 from `/internal/telegram-forward`
|
|
9
|
+
went to 3-attempts-then-drop replay policy; restart attempts blocked by
|
|
10
|
+
cooldown; manual operator recovery required).
|
|
11
|
+
|
|
12
|
+
## Summary of the change
|
|
13
|
+
|
|
14
|
+
Five interlocking fixes that together close the silent-drop failure
|
|
15
|
+
class. All independently revertable.
|
|
16
|
+
|
|
17
|
+
1. **Rate-limit cooldown bypass for `versionSkew` bucket.** A
|
|
18
|
+
no-op-against-mismatch wait is replaced with allow-but-rate-limit
|
|
19
|
+
(daily cap still applies). One-line condition change in
|
|
20
|
+
`decide()`.
|
|
21
|
+
|
|
22
|
+
2. **Drop policy guards on `versionSkewActive` instance flag.** Three
|
|
23
|
+
new state operations:
|
|
24
|
+
- `handleVersionSkew` sets `versionSkewActive = true` and fires
|
|
25
|
+
one user-visible alert (dedupe via `versionSkewAlertSentAt`).
|
|
26
|
+
- `forwardToServer` success clears the flag + dedupe timestamp.
|
|
27
|
+
- `replayQueue` re-queues without incrementing failures while
|
|
28
|
+
the flag is set.
|
|
29
|
+
|
|
30
|
+
3. **CLI service label fix.** `com.instar.<name>.lifeline` →
|
|
31
|
+
`ai.instar.<name>`. Matches the plist label written by
|
|
32
|
+
`installMacOSLaunchAgent`. Stops the always-fall-back-to-pkill path.
|
|
33
|
+
|
|
34
|
+
4. **pkill fallback escalates + lock-acquire recognizes wedged-S.**
|
|
35
|
+
CLI: SIGTERM, 3s grace, SIGKILL by pattern. Lock-acquire: detects
|
|
36
|
+
sleeping ('S') state with lock-write age > 5 min as a wedged
|
|
37
|
+
lifeline, sends its own SIGTERM → 3s poll → SIGKILL sequence.
|
|
38
|
+
|
|
39
|
+
5. **`--build-from-source` + `--ignore-scripts` on every native
|
|
40
|
+
rebuild.** Two call sites: ServerSupervisor preflight rebuild
|
|
41
|
+
(line 751) and NativeModuleHealer.healBetterSqlite3Sync
|
|
42
|
+
(line 397). The Remediator-orchestrated path already had these
|
|
43
|
+
flags (NativeModuleHealer.healBetterSqlite3FromRemediator).
|
|
44
|
+
|
|
45
|
+
Files touched (excluding tests / docs):
|
|
46
|
+
- `src/lifeline/rateLimitState.ts` — `decide()` cooldown bypass
|
|
47
|
+
- `src/lifeline/TelegramLifeline.ts` — instance state, `handleVersionSkew`,
|
|
48
|
+
`forwardToServer`, `replayQueue`, `acquireLockFile`
|
|
49
|
+
- `src/cli.ts` — label fix + pkill escalation
|
|
50
|
+
- `src/lifeline/ServerSupervisor.ts` — rebuild flags
|
|
51
|
+
- `src/memory/NativeModuleHealer.ts` — rebuild flags
|
|
52
|
+
|
|
53
|
+
## Decision matrix
|
|
54
|
+
|
|
55
|
+
### Over-block
|
|
56
|
+
|
|
57
|
+
| Scenario | Outcome |
|
|
58
|
+
|----------|---------|
|
|
59
|
+
| Lifeline at SAME version as server (no skew) | No change — `versionSkewActive` stays false |
|
|
60
|
+
| Lifeline at OLD version, server unchanged (no auto-update) | No change — no 426, no skew |
|
|
61
|
+
| Genuine transient 5xx | Drop-after-3 still fires (versionSkewActive is false) |
|
|
62
|
+
| `ForwardServerBootError` (server starting) | Drop-after-3 still fires — known limitation; this PR doesn't try to also branch on serverBoot. Out of scope. Justified: serverBoot is short-lived (seconds), version skew is steady-state until restart. |
|
|
63
|
+
| Lifeline restart loop after fix | Daily cap (3-per-24h) plus storm detection bound the loop |
|
|
64
|
+
|
|
65
|
+
No over-block. The new authority only fires when ALL of:
|
|
66
|
+
1. The forward threw `ForwardVersionSkewError`,
|
|
67
|
+
2. The server version in the body differs from the lifeline's,
|
|
68
|
+
3. The body's `upgradeRequired === true`.
|
|
69
|
+
|
|
70
|
+
That's the same gate that already triggers the restart request, so we
|
|
71
|
+
haven't widened the surface — just made the consequences during the
|
|
72
|
+
window correct.
|
|
73
|
+
|
|
74
|
+
### Under-block
|
|
75
|
+
|
|
76
|
+
| Scenario | Outcome |
|
|
77
|
+
|----------|---------|
|
|
78
|
+
| Lifeline version "skews" because of a malformed 426 body | Guarded by existing `body.upgradeRequired !== true` and `serverVersion === this.lifelineVersion` checks — they were already in `handleVersionSkew` |
|
|
79
|
+
| Lifeline tries to forge a skew to bypass cooldown | The 426 must come from the SERVER (loopback addr only on a privileged port); the lifeline cannot self-trigger the body shape |
|
|
80
|
+
| Two version-skew episodes in <24h (daily cap reached) | Falls through to existing `version-skew-daily-cap` denial — observability path remains intact |
|
|
81
|
+
| Stuck-lock detection: live healthy lifeline misidentified as wedged | Requires lock-age > 5 min AND 'S' state — a healthy lifeline writes the lock, immediately starts polling Telegram (lots of 'R' time), and answers heartbeats. Misidentification window is narrow but possible during long Telegram long-poll waits. Acceptable: false positive triggers SIGTERM → 3s grace → SIGKILL of a healthy lifeline → launchd respawns it within seconds. Worst case: ~5s downtime + a fresh boot. Far better than the 21h+ silent drop the current code permits. |
|
|
82
|
+
| Forced rebuild with `--build-from-source` fails because node-gyp toolchain absent | Already-existing failure path: spawnSync returns non-zero, healer logs error, falls back to existing escalation. No new silent failure. |
|
|
83
|
+
|
|
84
|
+
### Level of abstraction fit
|
|
85
|
+
|
|
86
|
+
- Rate-limit bypass — at the decision-point that already knows about
|
|
87
|
+
buckets. Right layer.
|
|
88
|
+
- Drop policy — at the replay loop that already has the failure-counter
|
|
89
|
+
state. Right layer. Could have lived in `forwardToServer` instead,
|
|
90
|
+
but the queue is what owns "should this message be dropped" — the
|
|
91
|
+
forward path only owns "did this single attempt succeed".
|
|
92
|
+
- CLI label — at the kickstart callsite. One source of truth.
|
|
93
|
+
- pkill escalation — at the CLI fallback. The CLI is the place where
|
|
94
|
+
"user typed `instar lifeline restart`" gets translated to a system
|
|
95
|
+
action.
|
|
96
|
+
- Lock-acquire — at the lock-acquire function. The detector and the
|
|
97
|
+
recovery primitive are both bounded mechanics in the same scope.
|
|
98
|
+
- Rebuild flags — at the rebuild callsites. No new abstraction.
|
|
99
|
+
|
|
100
|
+
No over-engineering. Each change is at the layer that already owns
|
|
101
|
+
the concern.
|
|
102
|
+
|
|
103
|
+
### Signal-vs-authority compliance
|
|
104
|
+
|
|
105
|
+
- `rateLimitState.decide` — returns signal. No authority change.
|
|
106
|
+
- `versionSkewActive` flag — pure state signal. Replay loop is the
|
|
107
|
+
authority that decides re-queue vs drop.
|
|
108
|
+
- `handleVersionSkew` — already had authority over "request a restart".
|
|
109
|
+
Now also: sets the flag and sends an alert. Both are bounded
|
|
110
|
+
mechanics with clear preconditions, not judgmental gates.
|
|
111
|
+
- CLI label — mechanical change.
|
|
112
|
+
- pkill escalation — bounded recovery (fixed 3s grace, single SIGKILL).
|
|
113
|
+
- Lock-acquire stuck-S — bounded recovery (fixed 5min threshold for
|
|
114
|
+
age, 3s SIGTERM grace, single SIGKILL).
|
|
115
|
+
- Rebuild flags — mechanical change.
|
|
116
|
+
|
|
117
|
+
Compliant. No new judgmental gates; new authorities are bounded
|
|
118
|
+
recovery primitives.
|
|
119
|
+
|
|
120
|
+
### Interactions with adjacent systems
|
|
121
|
+
|
|
122
|
+
| System | Interaction | Risk |
|
|
123
|
+
|--------|-------------|------|
|
|
124
|
+
| `MessageQueue` | Replay loop re-queues messages during skew (same enqueue path used for transient failures) | None — queue persistence semantics unchanged |
|
|
125
|
+
| `notifyMessageDropped` | NOT called during version-skew episodes (drop is suppressed) | Strictly improves — these messages will deliver |
|
|
126
|
+
| `DegradationReporter` | Still fires for non-skew drops; version-skew episode itself emits a degradation via the existing path | None |
|
|
127
|
+
| `RestartOrchestrator` | Receives a `versionSkew` request that's now more likely to be granted (cooldown bypass) | Storm detection + daily cap remain as backstops |
|
|
128
|
+
| `ServerSupervisor.preflightSelfHeal` | Rebuild flag change is a strict improvement | None |
|
|
129
|
+
| Remediator (Tier 1+) | Not yet shipped; in-line healer is the steady-state path. When Remediator ships, its rebuild already used `--build-from-source` — no conflict | None |
|
|
130
|
+
| `NativeModuleHealer.invokeFromRemediator` | Unchanged (already had the flag) | None |
|
|
131
|
+
| `LifelineHealthWatchdog` | Tracks `consecutiveForwardFailures`; this PR clears it on success (existing behavior). No change to the watchdog's trip conditions | None |
|
|
132
|
+
| `WatchdogTriggers.versionSkew` | The flag-set + alert-send happens BEFORE `initiateRestart`. If the restart is rate-limited, the alert still fires — good (user knows ingress is paused even while the lifeline is being patient about restarts) | None |
|
|
133
|
+
| `acquireLockFile` callers | The added SIGTERM-then-SIGKILL path runs in the boot-startup window. New lifelines that find a wedged old one will spend up to 3s waiting for graceful exit. Acceptable | None |
|
|
134
|
+
| Existing tests | All 18 `rateLimitState.test.ts` + 14 `lifeline-shadow-install-self-heal.test.ts` + 9 `NativeModuleHealer.test.ts` tests pass unchanged | Verified |
|
|
135
|
+
|
|
136
|
+
### Rollback cost
|
|
137
|
+
|
|
138
|
+
- Each fix is independently revertable.
|
|
139
|
+
- No persistent state schema changes; the new lifeline instance
|
|
140
|
+
fields (`versionSkewActive`, `versionSkewAlertSentAt`) are not
|
|
141
|
+
serialized to disk.
|
|
142
|
+
- No migration entry; existing in-the-wild agents pick up the fix on
|
|
143
|
+
their next `npx instar` update like any other patch.
|
|
144
|
+
- Reverting the rebuild flag returns to the pre-existing flaky
|
|
145
|
+
rebuild behavior — agents that had drifted to "rebuild succeeded
|
|
146
|
+
but still fails" on the old code path continue exactly as they
|
|
147
|
+
did.
|
|
148
|
+
|
|
149
|
+
## Acceptance criteria (test-mapped)
|
|
150
|
+
|
|
151
|
+
1. `tests/unit/lifeline/rateLimitState.test.ts > versionSkew bucket
|
|
152
|
+
BYPASSES the cooldown` — asserts the new bypass.
|
|
153
|
+
2. `tests/unit/lifeline/version-skew-recovery.test.ts > CLI service
|
|
154
|
+
label` — asserts `ai.instar.${...}` is in source and
|
|
155
|
+
`com.instar.${...}.lifeline` is gone.
|
|
156
|
+
3. `tests/unit/lifeline/version-skew-recovery.test.ts > pkill
|
|
157
|
+
fallback escalates to SIGKILL` — asserts both signal calls
|
|
158
|
+
present in the restart command source.
|
|
159
|
+
4. `tests/unit/lifeline/version-skew-recovery.test.ts >
|
|
160
|
+
ServerSupervisor preflight rebuild passes --build-from-source` —
|
|
161
|
+
asserts the rebuild argv on the supervisor side.
|
|
162
|
+
5. `tests/unit/lifeline/version-skew-recovery.test.ts >
|
|
163
|
+
NativeModuleHealer in-line rebuild passes --build-from-source` —
|
|
164
|
+
asserts EVERY rebuild call in the healer source has the flag.
|
|
165
|
+
6. `tests/unit/lifeline/version-skew-recovery.test.ts > lifeline
|
|
166
|
+
source guards drop with versionSkewActive` — asserts the source
|
|
167
|
+
order (skew bypass before drop check).
|
|
168
|
+
7. `tests/unit/lifeline/version-skew-recovery.test.ts >
|
|
169
|
+
handleVersionSkew sets the active flag + alert dedupe`.
|
|
170
|
+
8. `tests/unit/lifeline/version-skew-recovery.test.ts >
|
|
171
|
+
forwardToServer success clears the version-skew episode flag`.
|
|
172
|
+
9. `tests/unit/lifeline/version-skew-recovery.test.ts > lock-acquire
|
|
173
|
+
treats sleeping (S) state > 5 min as recoverable`.
|
|
174
|
+
|
|
175
|
+
## Forward note
|
|
176
|
+
|
|
177
|
+
If the auto-update flow eventually grows a "tell the lifeline to
|
|
178
|
+
restart" step, the failure class this PR addresses becomes
|
|
179
|
+
impossible-by-construction. Until then, this is the steady-state
|
|
180
|
+
recovery for the window between server-update and lifeline-restart.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Side-effects review — test-env-isolation
|
|
2
|
+
|
|
3
|
+
Spec: `docs/specs/test-env-isolation.md`
|
|
4
|
+
ELI16: `docs/specs/test-env-isolation.eli16.md`
|
|
5
|
+
|
|
6
|
+
## Surface map
|
|
7
|
+
|
|
8
|
+
| Layer | File(s) added / changed | Surface |
|
|
9
|
+
|------|------------------------|---------|
|
|
10
|
+
| 1 | `tests/vitest-setup.ts` | Process env at vitest startup. Every test inherits the strip. |
|
|
11
|
+
| 2 | `tests/helpers/git-test-env.ts` (new); refactors to `tests/unit/worktree-monitor.test.ts`, `tests/unit/SafeGitExecutor.test.ts`, `tests/integration/rich-profile-integration.test.ts` | Three fixtures' git-spawn calls now pass an explicit sanitized env. |
|
|
12
|
+
| 3 | `scripts/pre-push-fixture-guard.mjs` (new); `.husky/pre-push` (wire-in) | Pre-push hook gains a guard step that runs before the test suite. |
|
|
13
|
+
| 4 | `scripts/check-repo-invariants.mjs` (new); `.github/workflows/ci.yml` (new `invariants` job) | Standalone CI job, no dependencies, runs in parallel with type check. |
|
|
14
|
+
| 5 | Deletions: `file-0.txt`, `seed` | Repo root no longer carries fixture stowaways. |
|
|
15
|
+
|
|
16
|
+
Tests: 14 new across `tests/unit/vitest-setup-git-env-strip.test.ts`, `tests/unit/scripts/pre-push-fixture-guard.test.ts`, `tests/unit/scripts/check-repo-invariants.test.ts`.
|
|
17
|
+
|
|
18
|
+
## Over-block analysis
|
|
19
|
+
|
|
20
|
+
**Layer 1 strip.** Could a legitimate test *need* the inherited `GIT_DIR`? In our codebase, no — every existing fixture that touched git either operates on a tmpdir (which doesn't need it) or already strips the vars locally. We grepped `tests/` for any consumer of `process.env.GIT_DIR` and found zero direct readers. The strip cannot regress a test that depends on the override, because no such test exists.
|
|
21
|
+
|
|
22
|
+
If a future test needs to operate against the real repo (e.g., a meta-test that inspects the project's own git state), it can re-set the variable explicitly inside its own scope — the global strip removes the *inherited* value, not the *ability to set* one.
|
|
23
|
+
|
|
24
|
+
**Layer 3 fixture-author guard.** Could it block a legitimate commit? Possible failure cases:
|
|
25
|
+
|
|
26
|
+
- Author email of `test@test.com` is in the bad-author set. A real human who happens to use that email would be blocked. Mitigation: the email is in our internal fixture set; real contributors use their own emails. If a real case ever arises, `INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1` lets them through.
|
|
27
|
+
- Subject line `init` matches the pattern. A real "init" commit from a new repo bootstrap would trip the guard. Mitigation: bootstrap commits live on their own branch and aren't pushed against an established `upstream/main`, so the guard's `<base>..HEAD` range returns empty for them. Tested in `pre-push-fixture-guard.test.ts`.
|
|
28
|
+
|
|
29
|
+
**Layer 4 README line floor.** Could a legitimate README shrink below 100 lines? The current README is 271 lines and the project is growing, not shrinking. A deliberate slim-down would need `INSTAR_README_MIN_LINES` override, which is exactly the escape hatch.
|
|
30
|
+
|
|
31
|
+
## Under-block analysis
|
|
32
|
+
|
|
33
|
+
**Layers 1+2 don't catch process-spawning that re-injects the env.** If a future utility spawns `git` with an explicit `env: { GIT_DIR: '...' }`, layers 1+2 don't help — they only strip *inherited* env. Layer 3 catches the *consequence* (a pollution commit on the branch), so the failure class is still closed end-to-end, but layers 1+2 alone are not sufficient. This is acknowledged in the spec.
|
|
34
|
+
|
|
35
|
+
**Layer 3 doesn't catch destructive changes that don't match the historical signature.** A future test fixture that wrote `# Different Stub` to README would land it if the commit message and author look normal. Layer 4 catches the resulting line-count regression; together, layers 3 and 4 cover both the "known-pattern pollution" and "novel pollution that drops content" failure modes.
|
|
36
|
+
|
|
37
|
+
**Layer 4 doesn't enforce content correctness.** A README with 100+ lines of garbage would pass. We deliberately scoped Layer 4 to structural invariants only; content review is the job of human PR review.
|
|
38
|
+
|
|
39
|
+
## Level-of-abstraction fit
|
|
40
|
+
|
|
41
|
+
The fix lives at the right layer:
|
|
42
|
+
|
|
43
|
+
- The root cause is in *test setup*, so the fix is in `vitest-setup.ts` — the central, single point that runs before every test. We did not push the strip down into individual fixtures (would require N changes and could be missed); we did not push it up into the test runner config (the var would still leak into test files that import their own helpers that ran first).
|
|
44
|
+
- The push-time guard lives in `.husky/pre-push`, which is where push-time checks already live.
|
|
45
|
+
- The CI invariant is a standalone job, not a step bolted onto an existing job that might be skipped under failure-routing rules.
|
|
46
|
+
|
|
47
|
+
## Signal-vs-authority compliance
|
|
48
|
+
|
|
49
|
+
Layer 3 is a brittle pattern matcher (authors + subjects) — it's a **signal**. It cannot block a sufficiently-disguised commit, and it doesn't try to. Authority for refusing a commit rests with the deterministic layers (1, 2, 4) and human review. The signal exists to make the *common* failure mode loud, not to be the load-bearing protection.
|
|
50
|
+
|
|
51
|
+
The deterministic strip (Layer 1) is the load-bearing protection. The CI invariant (Layer 4) is the structural assertion. The signal (Layer 3) makes the failure visible early but is not the only line of defense.
|
|
52
|
+
|
|
53
|
+
## Interactions with existing systems
|
|
54
|
+
|
|
55
|
+
- **`pre-push-gate.js`** (NEXT.md / version check) runs first and remains unchanged.
|
|
56
|
+
- **Working-tree integrity check** (post-test step in `ci.yml`) catches *uncommitted* mutations; the new invariants job catches *committed* state. Complementary, not duplicate.
|
|
57
|
+
- **`lint-no-direct-destructive.js`** already requires `safe-git-allow:` on raw git use. Our new scripts carry it; the test files for those scripts carry it. No conflict.
|
|
58
|
+
- **`PostUpdateMigrator`** is not affected — none of these changes touch agent-installed files. The Migration Parity Standard does not apply.
|
|
59
|
+
|
|
60
|
+
## Rollback cost
|
|
61
|
+
|
|
62
|
+
All five layers are net-additive. Each can be reverted independently:
|
|
63
|
+
|
|
64
|
+
- Layer 1: revert five `delete` lines.
|
|
65
|
+
- Layer 2: revert the helper file and three fixture edits.
|
|
66
|
+
- Layer 3: comment out two lines in `.husky/pre-push` or delete the script.
|
|
67
|
+
- Layer 4: comment out the `invariants` job in `ci.yml`.
|
|
68
|
+
- Layer 5: re-create `file-0.txt` / `seed` — but they had no legitimate function, so no consumer breaks.
|
|
69
|
+
|
|
70
|
+
No data migration. No schema change. No agent-state mutation. Reverting any layer leaves the repo in a valid state and the test suite green.
|
|
71
|
+
|
|
72
|
+
## Risk summary
|
|
73
|
+
|
|
74
|
+
- **Low risk of regression.** Layer 1 is a single-line process.env mutation in test setup that affects nothing in production code. Layers 2–5 are net-additive guards.
|
|
75
|
+
- **Moderate risk of friction.** Layer 3 will be loud when triggered, including against legitimate edge cases (the documented bypass exists for this). Layer 4 will fail builds on cosmetic README slim-downs unless `INSTAR_README_MIN_LINES` is overridden.
|
|
76
|
+
- **No risk of data loss.** Nothing in this change mutates state outside the worktree.
|
|
77
|
+
|
|
78
|
+
## Verification done before commit
|
|
79
|
+
|
|
80
|
+
- All targeted test files pass locally (45 in worktree-monitor + SafeGitExecutor, 14 in new tests).
|
|
81
|
+
- `npm run lint` passes with the `safe-git-allow:` headers in place.
|
|
82
|
+
- `node scripts/check-repo-invariants.mjs` against the prepared worktree returns clean.
|
|
83
|
+
- `node scripts/pre-push-fixture-guard.mjs` against the prepared worktree returns clean.
|
|
84
|
+
- Spec carries `approved: true` per direct principal authorization in Telegram topic 11235.
|