@immediately-run/grove 0.1.4 → 0.1.7

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.
@@ -1,94 +1,512 @@
1
- // R-GA-1 / G-GA-1 / G-GA-6 / G-GA-10 — the reach card is a pure function of the
2
- // envelope; no pixel claims a capability the session lacks, causes are distinct and
3
- // concrete, and state flips recompute rows AND chips.
4
- import { describe, it, expect } from 'vitest';
5
- import { computeReachRows, reachChips, showEgressDisclosure } from './reachCard';
6
- import type { ChatProviderState } from '@immediately-run/sdk';
1
+ // R-GA-1 / G-GA-1 / G-GA-6 / G-GA-10 / R-SP-6 — the reach card is a pure function of
2
+ // the envelope; no pixel claims a capability the session lacks, causes are distinct
3
+ // and concrete, and state flips recompute rows AND chips. R3-752: the apply row is a
4
+ // destination (`elsewhere`, never ✗), the packaging substrate is its own neutral row,
5
+ // the source-trust copy is keyed on its basis, and no row carries a model noun.
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ computeReachRows,
9
+ reachChips,
10
+ sourceTrustLine,
11
+ showEgressDisclosure,
12
+ } from "./reachCard";
13
+ import type { ChatProviderState } from "@immediately-run/sdk";
7
14
 
8
15
  const configured = (tools = true): ChatProviderState => ({
9
- status: 'configured',
10
- provider: { providerId: 'llm.chat.anthropic', hostVouched: true, features: { vision: false, tools, jsonMode: true, reasoning: false, maxContextTokens: 100000 } },
16
+ status: "configured",
17
+ provider: {
18
+ providerId: "llm.chat.anthropic",
19
+ hostVouched: true,
20
+ features: {
21
+ vision: false,
22
+ tools,
23
+ jsonMode: true,
24
+ reasoning: false,
25
+ maxContextTokens: 100000,
26
+ },
27
+ },
11
28
  });
12
29
 
13
- const row = (rows: ReturnType<typeof computeReachRows>, key: string) => rows.find((r) => r.key === key)!;
30
+ const row = (rows: ReturnType<typeof computeReachRows>, key: string) =>
31
+ rows.find((r) => r.key === key)!;
14
32
 
15
- describe('G-GA-10 — the Q&A row renders the provider three-state honestly', () => {
16
- it('unknown renders NEUTRAL — no cause, no connect copy (the R3-300 rule)', () => {
17
- const rows = computeReachRows({ providerState: { status: 'unknown' }, chatGranted: true, writable: true, sourceShared: true });
18
- expect(row(rows, 'answer').state).toBe('neutral');
19
- expect(row(rows, 'answer').cause).toBeUndefined();
33
+ describe("G-GA-10 — the Q&A row renders the provider state honestly", () => {
34
+ it("unknown renders NEUTRAL — no cause, no connect copy (the R3-300 rule)", () => {
35
+ const rows = computeReachRows({
36
+ providerState: { status: "unknown" },
37
+ chatGranted: true,
38
+ catalogAnswered: true,
39
+ writable: true,
40
+ sourceShared: true,
41
+ mountId: null,
42
+ toolsSupported: true,
43
+ });
44
+ expect(row(rows, "answer").state).toBe("neutral");
45
+ expect(row(rows, "answer").cause).toBeUndefined();
20
46
  });
21
47
 
22
- it('not-configured names the KEY cause; ungranted names the CONSENT cause — never conflated', () => {
23
- const noKey = computeReachRows({ providerState: { status: 'not-configured' }, chatGranted: true, writable: true, sourceShared: false });
24
- expect(row(noKey, 'answer').state).toBe('blocked');
25
- expect(row(noKey, 'answer').cause).toContain('add one in Settings');
48
+ it("not-configured names the KEY cause; ungranted names the CONSENT cause — never conflated", () => {
49
+ const noKey = computeReachRows({
50
+ providerState: { status: "not-configured" },
51
+ chatGranted: true,
52
+ catalogAnswered: true,
53
+ writable: true,
54
+ sourceShared: false,
55
+ mountId: null,
56
+ toolsSupported: true,
57
+ });
58
+ expect(row(noKey, "answer").state).toBe("blocked");
59
+ expect(row(noKey, "answer").cause).toContain("add one in Settings");
26
60
 
27
- const forbidden = computeReachRows({ providerState: configured(), chatGranted: false, writable: true, sourceShared: false });
28
- expect(row(forbidden, 'answer').state).toBe('blocked');
29
- expect(row(forbidden, 'answer').cause).toContain("wasn't granted chat");
30
- expect(row(forbidden, 'answer').cause).not.toContain('Settings');
61
+ const forbidden = computeReachRows({
62
+ providerState: configured(),
63
+ chatGranted: false,
64
+ catalogAnswered: true,
65
+ writable: true,
66
+ sourceShared: false,
67
+ mountId: null,
68
+ toolsSupported: true,
69
+ });
70
+ expect(row(forbidden, "answer").state).toBe("blocked");
71
+ expect(row(forbidden, "answer").cause).toContain("wasn't granted chat");
72
+ expect(row(forbidden, "answer").cause).not.toContain("Settings");
31
73
  });
32
74
 
33
- it('configured + granted is ✓ and carries the read-flavored chips', () => {
34
- const ok = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
35
- expect(row(ok, 'answer').state).toBe('ok');
36
- expect(row(ok, 'answer').chips).toBeDefined();
75
+ it("the host-marked UNGRANTED state names the consent cause WITHOUT a configured provider (R3-688)", () => {
76
+ // The R3-688 exit: an ungranted fork is never told the provider, so before the
77
+ // host mark the consent cause was uncomputable — the fork read not-configured and
78
+ // rendered the KEY copy at a user who had one. The marked state fixes the cause
79
+ // with nothing but the grant decision.
80
+ const marked = computeReachRows({
81
+ providerState: { status: "ungranted" },
82
+ chatGranted: false,
83
+ catalogAnswered: true,
84
+ writable: true,
85
+ sourceShared: false,
86
+ mountId: null,
87
+ toolsSupported: true,
88
+ });
89
+ expect(row(marked, "answer").state).toBe("blocked");
90
+ expect(row(marked, "answer").cause).toContain("wasn't granted chat");
91
+ expect(row(marked, "answer").cause).not.toContain("Settings");
92
+ // The egress line stays hidden: no provider is bound for this frame.
93
+ expect(showEgressDisclosure({ status: "ungranted" })).toBe(false);
94
+ });
95
+
96
+ it("the mark ALONE blocks — ungranted with a granted-looking catalog (R3-688, isolating)", () => {
97
+ // THE test for this item, and it was missing: every other ungranted case also sets
98
+ // `chatGranted: false`, so `!chatGranted` satisfies them on its own and the whole
99
+ // R3-688 arm could be deleted with the suite green. Round 1 proved that by deleting
100
+ // `providerState.status === 'ungranted' ||` — 499/499 still passed.
101
+ //
102
+ // This is the one input that isolates the mark: a stale or racing catalog still
103
+ // advertising `llm:chat` beside a host that has marked the answer ungranted.
104
+ //
105
+ // What deleting the arm does now, measured: the input falls into the exhaustiveness
106
+ // branch and renders `neutral`, so line 59's state assertion is what catches it. It
107
+ // is `'ok'` WITH both chips — an unbacked capability claim, not merely a wrong cause
108
+ // — only before the tail was narrowed in the same commit, which is the state round 1
109
+ // found. Both are caught; the chip assertions below hold the stronger, older shape.
110
+ const marked = computeReachRows({
111
+ providerState: { status: "ungranted" },
112
+ chatGranted: true,
113
+ catalogAnswered: true,
114
+ writable: true,
115
+ sourceShared: false,
116
+ mountId: null,
117
+ toolsSupported: true,
118
+ });
119
+ expect(row(marked, "answer").state).toBe("blocked");
120
+ expect(row(marked, "answer").cause).toContain("wasn't granted chat");
121
+ expect(row(marked, "answer").cause).not.toContain("Settings");
122
+ expect(row(marked, "answer").chips).toBeUndefined();
123
+ expect(
124
+ reachChips(marked).some((c) => /summarize|tagged security/i.test(c)),
125
+ ).toBe(false);
126
+ });
127
+
128
+ it("an UNANSWERED catalog names no cause — neutral, not a guessed consent state", () => {
129
+ // `!chatGranted` is true both for "not granted" and for "the host has not replied",
130
+ // and the catalog's value cannot separate them (an empty catalog is a real answer).
131
+ // Guessing "not granted" claims a consent state about a frame that may hold the
132
+ // grant; guessing the ✓ arm claims a capability it may not have, which R-GA-1
133
+ // forbids outright. So: name nothing, exactly as `unknown` does one channel over.
134
+ const waiting = computeReachRows({
135
+ providerState: { status: "not-configured" },
136
+ chatGranted: false,
137
+ catalogAnswered: false,
138
+ writable: true,
139
+ sourceShared: false,
140
+ mountId: null,
141
+ toolsSupported: true,
142
+ });
143
+ expect(row(waiting, "answer").state).toBe("neutral");
144
+ expect(row(waiting, "answer").cause).toBeUndefined();
145
+ expect(row(waiting, "answer").chips).toBeUndefined();
146
+
147
+ // …and a CONFIGURED provider must not fall through to ✓ while the grant is unknown.
148
+ const keyed = computeReachRows({
149
+ providerState: configured(),
150
+ chatGranted: false,
151
+ catalogAnswered: false,
152
+ writable: true,
153
+ sourceShared: false,
154
+ mountId: null,
155
+ toolsSupported: true,
156
+ });
157
+ expect(row(keyed, "answer").state).toBe("neutral");
158
+ expect(
159
+ reachChips(keyed).some((c) => /summarize|tagged security/i.test(c)),
160
+ ).toBe(false);
161
+ });
162
+
163
+ it("the host MARK does not wait for the catalog — ungranted blocks with no answer at all", () => {
164
+ // The mark is authoritative on its own; gating it behind the catalog would reintroduce
165
+ // the very gap R3-688 closes.
166
+ const marked = computeReachRows({
167
+ providerState: { status: "ungranted" },
168
+ chatGranted: false,
169
+ catalogAnswered: false,
170
+ writable: true,
171
+ sourceShared: false,
172
+ mountId: null,
173
+ toolsSupported: true,
174
+ });
175
+ expect(row(marked, "answer").state).toBe("blocked");
176
+ expect(row(marked, "answer").cause).toContain("wasn't granted chat");
177
+ });
178
+
179
+ it("configured + granted is ✓ and carries the read-flavored chips", () => {
180
+ const ok = computeReachRows({
181
+ providerState: configured(),
182
+ chatGranted: true,
183
+ catalogAnswered: true,
184
+ writable: false,
185
+ sourceShared: false,
186
+ mountId: null,
187
+ toolsSupported: true,
188
+ });
189
+ expect(row(ok, "answer").state).toBe("ok");
190
+ expect(row(ok, "answer").chips).toBeDefined();
37
191
  });
38
192
  });
39
193
 
40
- describe('G-GA-1 — the read/draft/apply rows derive from writability, never copy', () => {
41
- it('read-only blocks Draft with the reader cause and yields NO write chip', () => {
42
- const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
43
- expect(row(rows, 'draft').state).toBe('blocked');
44
- expect(row(rows, 'draft').cause).toContain('reader');
194
+ describe("G-GA-1 — the read/draft/apply rows derive from writability, never copy", () => {
195
+ it("read-only blocks Draft with the reader cause and yields NO write chip", () => {
196
+ const rows = computeReachRows({
197
+ providerState: configured(),
198
+ chatGranted: true,
199
+ catalogAnswered: true,
200
+ writable: false,
201
+ sourceShared: false,
202
+ mountId: null,
203
+ toolsSupported: true,
204
+ });
205
+ expect(row(rows, "draft").state).toBe("blocked");
206
+ expect(row(rows, "draft").cause).toContain("reader");
45
207
  const chips = reachChips(rows);
46
- expect(chips.some((c) => /add an entry|fix broken|reorganize|timeline|suggest an edit/i.test(c))).toBe(false);
208
+ expect(
209
+ chips.some((c) =>
210
+ /add an entry|fix broken|reorganize|timeline|suggest an edit/i.test(c),
211
+ ),
212
+ ).toBe(false);
213
+ });
214
+
215
+ it("writable turns Draft ✓ — and its chip is a DESCRIBE, not an apply", () => {
216
+ const rows = computeReachRows({
217
+ providerState: configured(),
218
+ chatGranted: true,
219
+ catalogAnswered: true,
220
+ writable: true,
221
+ sourceShared: false,
222
+ mountId: null,
223
+ toolsSupported: true,
224
+ });
225
+ expect(row(rows, "draft").state).toBe("ok");
226
+ expect((row(rows, "draft").chips ?? []).join(" ")).toMatch(
227
+ /suggest an edit/i,
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("R3-752 — the apply row is a destination, across every input (R-GA-3)", () => {
233
+ // Every member of the union, including the one R3-688 adds — the spec calls this sweep
234
+ // the mechanical guarantee for R-SP-6, and it was enumerating three of four, missing
235
+ // exactly the state this item is about.
236
+ const states: ChatProviderState[] = [
237
+ { status: "unknown" },
238
+ { status: "not-configured" },
239
+ { status: "ungranted" },
240
+ configured(true),
241
+ configured(false),
242
+ ];
243
+ // The full cross-product of {writable} × {sourceShared} × {provider states}: NO input
244
+ // can put the apply row back to blocked or put the trust sentence back on it.
245
+ for (const writable of [true, false]) {
246
+ for (const sourceShared of [true, false]) {
247
+ for (const providerState of states) {
248
+ it(`writable=${writable} sourceShared=${sourceShared} provider=${providerState.status} → elsewhere with a destination and no cause`, () => {
249
+ const rows = computeReachRows({
250
+ providerState,
251
+ chatGranted: true,
252
+ catalogAnswered: true,
253
+ writable,
254
+ sourceShared,
255
+ mountId: "space:abc",
256
+ toolsSupported: true,
257
+ });
258
+ expect(row(rows, "apply").state).toBe("elsewhere");
259
+ expect(row(rows, "apply").destination).toContain(
260
+ "editor or workbench",
261
+ );
262
+ expect(row(rows, "apply").cause).toBeUndefined();
263
+ });
264
+ }
265
+ }
266
+ }
267
+
268
+ it("the source-trust sentence is a property of the SOURCE — never on the apply row", () => {
269
+ const rows = computeReachRows({
270
+ providerState: configured(),
271
+ chatGranted: true,
272
+ catalogAnswered: true,
273
+ writable: true,
274
+ sourceShared: true,
275
+ mountId: null,
276
+ toolsSupported: true,
277
+ });
278
+ expect(JSON.stringify(row(rows, "apply"))).not.toContain("others can");
279
+ expect(JSON.stringify(row(rows, "apply"))).not.toContain(
280
+ "push to this repo",
281
+ );
282
+ });
283
+ });
284
+
285
+ describe("R3-752 — the packaging row states the substrate, as context (neutral, no chips)", () => {
286
+ it("mountId: null is the own-corpus row; an id is the mounted row", () => {
287
+ const own = computeReachRows({
288
+ providerState: configured(),
289
+ chatGranted: true,
290
+ catalogAnswered: true,
291
+ writable: true,
292
+ sourceShared: true,
293
+ mountId: null,
294
+ toolsSupported: true,
295
+ });
296
+ expect(row(own, "packaging").label).toBe("Reads its own entries");
297
+ expect(row(own, "packaging").state).toBe("neutral");
298
+
299
+ const mounted = computeReachRows({
300
+ providerState: configured(),
301
+ chatGranted: true,
302
+ catalogAnswered: true,
303
+ writable: true,
304
+ sourceShared: true,
305
+ mountId: "space:abc",
306
+ toolsSupported: true,
307
+ });
308
+ expect(row(mounted, "packaging").label).toBe(
309
+ "Reads a wiki mounted into it",
310
+ );
311
+ expect(row(mounted, "packaging").state).toBe("neutral");
47
312
  });
48
313
 
49
- it('writable turns Draft ✓ — and its chip is a DESCRIBE, not an apply', () => {
50
- const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
51
- expect(row(rows, 'draft').state).toBe('ok');
52
- expect((row(rows, 'draft').chips ?? []).join(' ')).toMatch(/suggest an edit/i);
314
+ it("the packaging row contributes no chips — either packaging, same chip set", () => {
315
+ const own = reachChips(
316
+ computeReachRows({
317
+ providerState: configured(),
318
+ chatGranted: true,
319
+ catalogAnswered: true,
320
+ writable: true,
321
+ sourceShared: true,
322
+ mountId: null,
323
+ toolsSupported: true,
324
+ }),
325
+ );
326
+ const mounted = reachChips(
327
+ computeReachRows({
328
+ providerState: configured(),
329
+ chatGranted: true,
330
+ catalogAnswered: true,
331
+ writable: true,
332
+ sourceShared: true,
333
+ mountId: "space:abc",
334
+ toolsSupported: true,
335
+ }),
336
+ );
337
+ expect(mounted).toEqual(own);
53
338
  });
339
+ });
340
+
341
+ describe("R3-752 — the source-trust line is keyed on WHY the source reads as shared", () => {
342
+ it("the two bases produce different strings, neither on a row", () => {
343
+ const git = sourceTrustLine(true, "git-indeterminate");
344
+ const mount = sourceTrustLine(true, "mount-trust-mode");
345
+ expect(git).toContain("push to this repo");
346
+ expect(mount).toContain("others can change");
347
+ expect(git).not.toBe(mount);
348
+ });
349
+
350
+ it("a source that is not shared renders NO line — no reassurance about a regime not running", () => {
351
+ expect(sourceTrustLine(false, "git-indeterminate")).toBeNull();
352
+ expect(sourceTrustLine(false, "mount-trust-mode")).toBeNull();
353
+ });
354
+ });
54
355
 
55
- it('Apply is NEVER ok from this panel (R-GA-3) — the cause says where changes go', () => {
356
+ describe("R3-752 — R-SP-6: no model nouns anywhere on the card, checked mechanically", () => {
357
+ // Every member of the union, including the one R3-688 adds — the spec calls this sweep
358
+ // the mechanical guarantee for R-SP-6, and it was enumerating three of four, missing
359
+ // exactly the state this item is about.
360
+ const states: ChatProviderState[] = [
361
+ { status: "unknown" },
362
+ { status: "not-configured" },
363
+ { status: "ungranted" },
364
+ configured(true),
365
+ configured(false),
366
+ ];
367
+ const banned =
368
+ /\b(taint|tainted|tier|principal|trust mode|capability|grant|shared source|low-trust)\b/i;
369
+
370
+ it("no string any row produces matches the banned vocabulary, over the full cross-product", () => {
56
371
  for (const writable of [true, false]) {
57
- const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable, sourceShared: false });
58
- expect(row(rows, 'apply').state).toBe('blocked');
59
- expect(row(rows, 'apply').cause).toContain('editor / workbench');
372
+ for (const sourceShared of [true, false]) {
373
+ for (const providerState of states) {
374
+ for (const mountId of [null, "space:abc"]) {
375
+ for (const toolsSupported of [true, false]) {
376
+ for (const chatGranted of [true, false]) {
377
+ for (const catalogAnswered of [true, false]) {
378
+ const rows = computeReachRows({
379
+ providerState,
380
+ chatGranted,
381
+ catalogAnswered,
382
+ writable,
383
+ sourceShared,
384
+ mountId,
385
+ toolsSupported,
386
+ });
387
+ const strings = rows.flatMap((r) =>
388
+ [
389
+ r.label,
390
+ r.cause,
391
+ r.destination,
392
+ ...(r.chips ?? []),
393
+ ].filter((s): s is string => typeof s === "string"),
394
+ );
395
+ for (const s of strings) expect(s).not.toMatch(banned);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ });
404
+
405
+ it("the source-trust lines pass the same vocabulary rule", () => {
406
+ for (const basis of ["git-indeterminate", "mount-trust-mode"] as const) {
407
+ const line = sourceTrustLine(true, basis)!;
408
+ expect(line).not.toMatch(banned);
60
409
  }
61
410
  });
411
+ });
412
+
413
+ describe("R3-752 / G-GA-8 — the tool-less degrade is a visible qualifier, never a silent downgrade", () => {
414
+ it("configured + granted + tools=false keeps row 1 ✓ and adds the degrade qualifier", () => {
415
+ const degraded = computeReachRows({
416
+ providerState: configured(false),
417
+ chatGranted: true,
418
+ catalogAnswered: true,
419
+ writable: true,
420
+ sourceShared: true,
421
+ mountId: null,
422
+ toolsSupported: false,
423
+ });
424
+ expect(row(degraded, "answer").state).toBe("ok");
425
+ expect(row(degraded, "answer").label).toContain(
426
+ "reads a summary of this wiki",
427
+ );
428
+ expect(row(degraded, "answer").chips).toBeDefined();
429
+ });
430
+
431
+ it("tools=true restores the plain label; a blocked row never grows the qualifier", () => {
432
+ const fine = computeReachRows({
433
+ providerState: configured(true),
434
+ chatGranted: true,
435
+ catalogAnswered: true,
436
+ writable: true,
437
+ sourceShared: true,
438
+ mountId: null,
439
+ toolsSupported: true,
440
+ });
441
+ expect(row(fine, "answer").label).toBe("Answer questions about this wiki");
62
442
 
63
- it('the shared-source sentence rides the Apply cause only when trust says others can write', () => {
64
- const shared = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: true });
65
- expect(row(shared, 'apply').cause).toContain('treated as if others can write');
66
- const solo = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
67
- expect(row(solo, 'apply').cause).not.toContain('others can write');
443
+ const noKey = computeReachRows({
444
+ providerState: { status: "not-configured" },
445
+ chatGranted: true,
446
+ catalogAnswered: true,
447
+ writable: true,
448
+ sourceShared: true,
449
+ mountId: null,
450
+ toolsSupported: false,
451
+ });
452
+ expect(row(noKey, "answer").label).toBe("Answer questions about this wiki");
453
+ expect(row(noKey, "answer").cause).toContain("add one in Settings");
68
454
  });
69
455
  });
70
456
 
71
- describe('G-GA-6 — envelope flips recompute rows and chips together', () => {
72
- it('disconnecting the provider flips answer ✓→blocked and drops its chips', () => {
73
- const before = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
74
- const after = computeReachRows({ providerState: { status: 'not-configured' }, chatGranted: true, writable: true, sourceShared: false });
75
- expect(row(before, 'answer').state).toBe('ok');
76
- expect(row(after, 'answer').state).toBe('blocked');
77
- expect(reachChips(after)).not.toContain('Summarize this entry');
457
+ describe("G-GA-6 — envelope flips recompute rows and chips together", () => {
458
+ it("disconnecting the provider flips answer ✓→blocked and drops its chips", () => {
459
+ const before = computeReachRows({
460
+ providerState: configured(),
461
+ chatGranted: true,
462
+ catalogAnswered: true,
463
+ writable: true,
464
+ sourceShared: false,
465
+ mountId: null,
466
+ toolsSupported: true,
467
+ });
468
+ const after = computeReachRows({
469
+ providerState: { status: "not-configured" },
470
+ chatGranted: true,
471
+ catalogAnswered: true,
472
+ writable: true,
473
+ sourceShared: false,
474
+ mountId: null,
475
+ toolsSupported: true,
476
+ });
477
+ expect(row(before, "answer").state).toBe("ok");
478
+ expect(row(after, "answer").state).toBe("blocked");
479
+ expect(reachChips(after)).not.toContain("Summarize this entry");
78
480
  });
79
481
 
80
- it('revoking write flips draft ✓→blocked on the next computation', () => {
81
- const before = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
82
- const after = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
83
- expect(row(before, 'draft').state).toBe('ok');
84
- expect(row(after, 'draft').state).toBe('blocked');
482
+ it("revoking write flips draft ✓→blocked on the next computation", () => {
483
+ const before = computeReachRows({
484
+ providerState: configured(),
485
+ chatGranted: true,
486
+ catalogAnswered: true,
487
+ writable: true,
488
+ sourceShared: false,
489
+ mountId: null,
490
+ toolsSupported: true,
491
+ });
492
+ const after = computeReachRows({
493
+ providerState: configured(),
494
+ chatGranted: true,
495
+ catalogAnswered: true,
496
+ writable: false,
497
+ sourceShared: false,
498
+ mountId: null,
499
+ toolsSupported: true,
500
+ });
501
+ expect(row(before, "draft").state).toBe("ok");
502
+ expect(row(after, "draft").state).toBe("blocked");
85
503
  });
86
504
  });
87
505
 
88
- describe('R-GA-6 — the egress disclosure is unconditional when a provider is bound', () => {
89
- it('shows for configured, not for unknown or not-configured, whatever the trust mode', () => {
506
+ describe("R-GA-6 — the egress disclosure is unconditional when a provider is bound", () => {
507
+ it("shows for configured, not for unknown or not-configured, whatever the trust mode", () => {
90
508
  expect(showEgressDisclosure(configured())).toBe(true);
91
- expect(showEgressDisclosure({ status: 'unknown' })).toBe(false);
92
- expect(showEgressDisclosure({ status: 'not-configured' })).toBe(false);
509
+ expect(showEgressDisclosure({ status: "unknown" })).toBe(false);
510
+ expect(showEgressDisclosure({ status: "not-configured" })).toBe(false);
93
511
  });
94
512
  });