@intentic/sandbox-contract 1.217.0 → 1.218.1

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.
@@ -2,11 +2,18 @@ import { STATE_DIR } from "@intentic/constants";
2
2
  import type { FileContribution } from "@intentic/extension-manifest";
3
3
  import { describe, expect, it } from "vitest";
4
4
  import {
5
+ BACKED_UP_STATE_PATHS,
5
6
  isLockedWorkspacePath,
6
7
  isReportedManifest,
7
8
  isReviewableLockedPath,
8
9
  REPORTED_MANIFEST_PATHS,
10
+ SEARCHABLE_STATE_PATHS,
11
+ STATE_GROUP_DIR,
12
+ type StateGroup,
13
+ stateGroupOf,
14
+ stateGroupPaths,
9
15
  staleQueryKeys,
16
+ UNBACKED_STATE_PATHS,
10
17
  VERSIONED_STATE_PATHS,
11
18
  WORKSPACE_STATE_FILES,
12
19
  } from "./workspace-state.js";
@@ -16,13 +23,13 @@ import {
16
23
  // the dependency direction, so reaching back for them here would invert it. The real manifests are checked
17
24
  // against this rule where they are loaded — web's fileBindings.test.ts and the daemon's file-bindings.test.ts.
18
25
  const AUTOMATIONS: readonly FileContribution[] = [
19
- { path: `${STATE_DIR}/automations.json`, invalidates: [`automations`] },
20
- { path: `${STATE_DIR}/approvals/`, invalidates: [`automation-approvals`] },
26
+ { path: `${STATE_DIR}/config/automations.json`, invalidates: [`automations`] },
27
+ { path: `${STATE_DIR}/records/approvals/`, invalidates: [`automation-approvals`] },
21
28
  ];
22
29
 
23
30
  describe(`staleQueryKeys`, () => {
24
31
  it(`maps a manifest write to the queries it makes stale`, () => {
25
- expect(staleQueryKeys([`.intentic/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`, `manifests`]);
32
+ expect(staleQueryKeys([`.intentic/config/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`, `manifests`]);
26
33
  });
27
34
 
28
35
  it(`refreshes the unreadable-manifest notice for the three files a person hand-edits`, () => {
@@ -30,33 +37,37 @@ describe(`staleQueryKeys`, () => {
30
37
  // the twenty-odd DAEMON-written ones cannot introduce a typo, and billing every browser a refetch for
31
38
  // each of them is the amplification this table exists to avoid.
32
39
  const carries = WORKSPACE_STATE_FILES.filter((file) => file.invalidates.includes(`manifests`)).map((file) => file.path);
33
- expect(carries.toSorted()).toEqual([`.intentic/capabilities.json`, `.intentic/personas.json`, `.intentic/settings.json`]);
40
+ expect(carries.toSorted()).toEqual([
41
+ `.intentic/config/capabilities.json`,
42
+ `.intentic/config/personas.json`,
43
+ `.intentic/config/settings.json`,
44
+ ]);
34
45
  });
35
46
 
36
47
  it(`matches a name family and a one-file-per-entry directory through one prefix each`, () => {
37
48
  // environment.Dockerfile, environment.custom.Dockerfile, environment.approved.Dockerfile — one entry.
38
- expect(staleQueryKeys([`.intentic/environment.custom.Dockerfile`], [])).toEqual([`environment`]);
39
- expect(staleQueryKeys([`.intentic/drafts/post-1.json`], [])).toEqual([`drafts`]);
49
+ expect(staleQueryKeys([`.intentic/config/environment.custom.Dockerfile`], [])).toEqual([`environment`]);
50
+ expect(staleQueryKeys([`.intentic/config/drafts/post-1.json`], [])).toEqual([`drafts`]);
40
51
  });
41
52
 
42
53
  it(`refreshes the Drafts view when the AGENT writes a draft`, () => {
43
54
  // The regression this table was reorganized around: the drafts skill writes these files directly, so
44
55
  // there is no browser mutation to hang an invalidate on — the watcher push is the only signal, and it
45
56
  // used to be dropped on the floor.
46
- expect(staleQueryKeys([`.intentic/drafts/post-1.json`], [])).toEqual([`drafts`]);
57
+ expect(staleQueryKeys([`.intentic/config/drafts/post-1.json`], [])).toEqual([`drafts`]);
47
58
  });
48
59
 
49
60
  it(`ignores unrelated churn under .intentic/`, () => {
50
61
  // The amplification that turned an iq index rebuild into an endless request storm: a prefix test on
51
62
  // `.intentic/` alone would invalidate every one of these queries for each index write.
52
- expect(staleQueryKeys([`.intentic/cache/iq/index.db`, `.intentic/sessions/claude/projects/p/session.jsonl`], [])).toEqual([]);
63
+ expect(staleQueryKeys([`.intentic/local/cache/iq/index.db`, `.intentic/records/sessions/claude/projects/p/session.jsonl`], [])).toEqual([]);
53
64
  });
54
65
 
55
66
  it(`ignores a store's own temp file while it is mid-swap`, () => {
56
67
  // jsonFile writes `.<name>.<pid>.tmp` beside the target precisely so the atomic rename can't be read as
57
68
  // a write to the target itself. A trailing-tag temp would prefix-match and bill an extra refetch.
58
69
  expect(staleQueryKeys([`.intentic/.settings.json.42.tmp`], [])).toEqual([]);
59
- expect(staleQueryKeys([`.intentic/settings.json`], [])).toEqual([`settings`, `manifests`]);
70
+ expect(staleQueryKeys([`.intentic/config/settings.json`], [])).toEqual([`settings`, `manifests`]);
60
71
  });
61
72
 
62
73
  it(`ignores ordinary workspace edits`, () => {
@@ -65,7 +76,7 @@ describe(`staleQueryKeys`, () => {
65
76
 
66
77
  it(`dedupes keys across a batch that touches several manifests`, () => {
67
78
  // A capability add recomposes the overlay, so both entries claim `environment` — one refetch, not two.
68
- expect(staleQueryKeys([`.intentic/capabilities.json`, `.intentic/environment.Dockerfile`], [])).toEqual([
79
+ expect(staleQueryKeys([`.intentic/config/capabilities.json`, `.intentic/config/environment.Dockerfile`], [])).toEqual([
69
80
  `capabilities`,
70
81
  `environment`,
71
82
  `panels`,
@@ -74,33 +85,33 @@ describe(`staleQueryKeys`, () => {
74
85
  });
75
86
 
76
87
  it(`invalidates an extension's queries from its own declaration`, () => {
77
- expect(staleQueryKeys([`.intentic/automations.json`], AUTOMATIONS)).toEqual([`automations`]);
78
- expect(staleQueryKeys([`.intentic/approvals/a1.json`], AUTOMATIONS)).toEqual([`automation-approvals`]);
88
+ expect(staleQueryKeys([`.intentic/config/automations.json`], AUTOMATIONS)).toEqual([`automations`]);
89
+ expect(staleQueryKeys([`.intentic/records/approvals/a1.json`], AUTOMATIONS)).toEqual([`automation-approvals`]);
79
90
  });
80
91
 
81
92
  it(`makes nothing stale for an extension that is not running`, () => {
82
93
  // The reason the live set is passed in rather than read off the installed list: `automations` is the
83
94
  // extension's query key, so with the extension gone there is no cache entry for it to be about. The core
84
95
  // table used to carry these two keys itself, and would have kept invalidating them either way.
85
- expect(staleQueryKeys([`.intentic/automations.json`, `.intentic/approvals/a1.json`], [])).toEqual([]);
96
+ expect(staleQueryKeys([`.intentic/config/automations.json`, `.intentic/records/approvals/a1.json`], [])).toEqual([]);
86
97
  });
87
98
 
88
99
  it(`lets an extension claim a path the core table deliberately ignores`, () => {
89
100
  // The two lists are unioned flat, not layered: a narrow extension entry under a broad core entry that
90
101
  // invalidates nothing must still fire. Without this, every path beneath one of the daemon's
91
102
  // machine-state prefixes would be unreachable to extensions.
92
- const nested: readonly FileContribution[] = [{ path: `${STATE_DIR}/sessions/claude/projects/p/memory/`, invalidates: [`memory`] }];
93
- expect(staleQueryKeys([`.intentic/sessions/claude/projects/p/memory/note.md`], nested)).toEqual([`memory`]);
103
+ const nested: readonly FileContribution[] = [{ path: `${STATE_DIR}/records/sessions/claude/projects/p/memory/`, invalidates: [`memory`] }];
104
+ expect(staleQueryKeys([`.intentic/records/sessions/claude/projects/p/memory/note.md`], nested)).toEqual([`memory`]);
94
105
  // …and a sibling under the same core prefix stays ignored.
95
- expect(staleQueryKeys([`.intentic/sessions/claude/projects/p/session.jsonl`], nested)).toEqual([]);
106
+ expect(staleQueryKeys([`.intentic/records/sessions/claude/projects/p/session.jsonl`], nested)).toEqual([]);
96
107
  });
97
108
 
98
109
  it(`dedupes a key two extensions both claim`, () => {
99
110
  const twice: readonly FileContribution[] = [
100
- { path: `${STATE_DIR}/automations.json`, invalidates: [`automations`] },
101
- { path: `${STATE_DIR}/automations.json`, invalidates: [`automations`] },
111
+ { path: `${STATE_DIR}/config/automations.json`, invalidates: [`automations`] },
112
+ { path: `${STATE_DIR}/config/automations.json`, invalidates: [`automations`] },
102
113
  ];
103
- expect(staleQueryKeys([`.intentic/automations.json`], twice)).toEqual([`automations`]);
114
+ expect(staleQueryKeys([`.intentic/config/automations.json`], twice)).toEqual([`automations`]);
104
115
  });
105
116
  });
106
117
 
@@ -113,7 +124,7 @@ describe(`isReportedManifest`, () => {
113
124
  .map((file) => file.path)
114
125
  .toSorted(),
115
126
  );
116
- expect(isReportedManifest(`.intentic/settings.json`)).toBe(true);
127
+ expect(isReportedManifest(`.intentic/config/settings.json`)).toBe(true);
117
128
  });
118
129
 
119
130
  it(`stays quiet about daemon-written state the owner cannot repair`, () => {
@@ -121,13 +132,13 @@ describe(`isReportedManifest`, () => {
121
132
  // field, so every read reported the whole file ignored — advice ("fix the file") addressed to nobody,
122
133
  // about sixty kilobytes of machine JSON, and refreshed by no write because the ledger feeds no query, so
123
134
  // it sat on the card until the daemon restarted. A ledger recovers on its own next write instead.
124
- expect(isReportedManifest(`.intentic/workflow-runs.json`)).toBe(false);
125
- expect(isReportedManifest(`.intentic/loops.json`)).toBe(false);
126
- expect(isReportedManifest(`.intentic/thread-sessions.json`)).toBe(false);
135
+ expect(isReportedManifest(`.intentic/records/workflow-runs.json`)).toBe(false);
136
+ expect(isReportedManifest(`.intentic/records/loops.json`)).toBe(false);
137
+ expect(isReportedManifest(`.intentic/records/thread-sessions.json`)).toBe(false);
127
138
  });
128
139
 
129
140
  it(`reads a platform path with either separator`, () => {
130
- expect(isReportedManifest(`.intentic\\settings.json`)).toBe(true);
141
+ expect(isReportedManifest(`.intentic\\config\\settings.json`)).toBe(true);
131
142
  });
132
143
 
133
144
  it(`does not report a file outside the workspace`, () => {
@@ -159,7 +170,7 @@ describe(`WORKSPACE_STATE_FILES`, () => {
159
170
  });
160
171
 
161
172
  it(`keeps directory entries slash-terminated so they cannot swallow a sibling`, () => {
162
- // `.intentic/drafts` without the slash would also prefix-match a future `.intentic/drafts-archive.json`.
173
+ // `.intentic/config/drafts` without the slash would also prefix-match a future `.intentic/drafts-archive.json`.
163
174
  for (const file of WORKSPACE_STATE_FILES.filter((entry) => entry.invalidates.length > 0)) {
164
175
  const isFamilyPrefix = file.path.endsWith(`.`);
165
176
  const isFile = file.path.endsWith(`.json`) || file.path.endsWith(`.Dockerfile`);
@@ -199,19 +210,19 @@ describe(`WORKSPACE_STATE_FILES`, () => {
199
210
  * padlock on a row and the refusal behind it can no longer disagree. */
200
211
  describe(`isLockedWorkspacePath`, () => {
201
212
  it(`covers the root state dir's credential entries, and their subtrees whole`, () => {
202
- expect(isLockedWorkspacePath(`.intentic/capabilities.json`)).toBe(true);
203
- expect(isLockedWorkspacePath(`.intentic/owner.json`)).toBe(true);
213
+ expect(isLockedWorkspacePath(`.intentic/config/capabilities.json`)).toBe(true);
214
+ expect(isLockedWorkspacePath(`.intentic/identity/owner.json`)).toBe(true);
204
215
  // A whole lifecycle root, so a provider added under it is covered without a second edit.
205
- expect(isLockedWorkspacePath(`.intentic/auth`)).toBe(true);
206
- expect(isLockedWorkspacePath(`.intentic/auth/codex/auth.json`)).toBe(true);
207
- expect(isLockedWorkspacePath(`.intentic/browser/Default/Cookies`)).toBe(true);
216
+ expect(isLockedWorkspacePath(`.intentic/secrets/auth`)).toBe(true);
217
+ expect(isLockedWorkspacePath(`.intentic/secrets/auth/codex/auth.json`)).toBe(true);
218
+ expect(isLockedWorkspacePath(`.intentic/local/browser/Default/Cookies`)).toBe(true);
208
219
  });
209
220
 
210
221
  it(`leaves the state dir's ordinary manifests alone`, () => {
211
222
  // The dir itself is browsable, and most of what is in it is a file a person may legitimately read.
212
223
  expect(isLockedWorkspacePath(`.intentic`)).toBe(false);
213
- expect(isLockedWorkspacePath(`.intentic/settings.json`)).toBe(false);
214
- expect(isLockedWorkspacePath(`.intentic/drafts/post-1.json`)).toBe(false);
224
+ expect(isLockedWorkspacePath(`.intentic/config/settings.json`)).toBe(false);
225
+ expect(isLockedWorkspacePath(`.intentic/config/drafts/post-1.json`)).toBe(false);
215
226
  });
216
227
 
217
228
  it(`locks the ROOT's own .git and nobody else's`, () => {
@@ -221,12 +232,12 @@ describe(`isLockedWorkspacePath`, () => {
221
232
  expect(isLockedWorkspacePath(`.git/config`)).toBe(true);
222
233
  expect(isLockedWorkspacePath(`myrepo/.git/config`)).toBe(false);
223
234
  // …and a repo's own nested state dir is its project's, not the daemon's.
224
- expect(isLockedWorkspacePath(`myrepo/.intentic/capabilities.json`)).toBe(false);
235
+ expect(isLockedWorkspacePath(`myrepo/.intentic/config/capabilities.json`)).toBe(false);
225
236
  });
226
237
 
227
238
  it(`reads a platform path the same as a posix one`, () => {
228
- expect(isLockedWorkspacePath(`.intentic\\auth\\codex`)).toBe(true);
229
- expect(isLockedWorkspacePath(`./.intentic/capabilities.json`)).toBe(true);
239
+ expect(isLockedWorkspacePath(`.intentic\\secrets\\auth\\codex`)).toBe(true);
240
+ expect(isLockedWorkspacePath(`./.intentic/config/capabilities.json`)).toBe(true);
230
241
  });
231
242
  });
232
243
 
@@ -234,30 +245,30 @@ describe(`isLockedWorkspacePath`, () => {
234
245
  * a diff by construction, and refusing to serve it made the Changes panel list a row it could not open. */
235
246
  describe(`isReviewableLockedPath`, () => {
236
247
  it(`admits the locked entry the root repo tracks, and nothing else locked`, () => {
237
- expect(isReviewableLockedPath(`.intentic/capabilities.json`)).toBe(true);
248
+ expect(isReviewableLockedPath(`.intentic/config/capabilities.json`)).toBe(true);
238
249
  // Every other locked entry is a credential, an identity binding or private runtime state. None is
239
250
  // versioned, so none is reachable through a diff — the carve-out cannot widen without the flag.
240
- expect(isReviewableLockedPath(`.intentic/owner.json`)).toBe(false);
241
- expect(isReviewableLockedPath(`.intentic/members.json`)).toBe(false);
242
- expect(isReviewableLockedPath(`.intentic/ci.json`)).toBe(false);
243
- expect(isReviewableLockedPath(`.intentic/auth/codex/auth.json`)).toBe(false);
244
- expect(isReviewableLockedPath(`.intentic/sessions/claude/x.jsonl`)).toBe(false);
245
- expect(isReviewableLockedPath(`.intentic/browser/Default/Cookies`)).toBe(false);
251
+ expect(isReviewableLockedPath(`.intentic/identity/owner.json`)).toBe(false);
252
+ expect(isReviewableLockedPath(`.intentic/identity/members.json`)).toBe(false);
253
+ expect(isReviewableLockedPath(`.intentic/secrets/ci.json`)).toBe(false);
254
+ expect(isReviewableLockedPath(`.intentic/secrets/auth/codex/auth.json`)).toBe(false);
255
+ expect(isReviewableLockedPath(`.intentic/records/sessions/claude/x.jsonl`)).toBe(false);
256
+ expect(isReviewableLockedPath(`.intentic/local/browser/Default/Cookies`)).toBe(false);
246
257
  expect(isReviewableLockedPath(`.git/config`)).toBe(false);
247
258
  });
248
259
 
249
260
  it(`answers only for the locked set — an ordinary path was never refused to begin with`, () => {
250
261
  // Tracked, but not locked: the guards never ask this of them, and a `true` here would read as "this
251
262
  // path needed a carve-out", which is a different and wrong statement.
252
- expect(isReviewableLockedPath(`.intentic/settings.json`)).toBe(false);
263
+ expect(isReviewableLockedPath(`.intentic/config/settings.json`)).toBe(false);
253
264
  expect(isReviewableLockedPath(`src/app.ts`)).toBe(false);
254
265
  // A repo's own nested state dir is its project's content, exactly as the lock reads it.
255
- expect(isReviewableLockedPath(`myrepo/.intentic/capabilities.json`)).toBe(false);
266
+ expect(isReviewableLockedPath(`myrepo/.intentic/config/capabilities.json`)).toBe(false);
256
267
  });
257
268
 
258
269
  it(`reads a platform path and a dot-relative one the same as a posix one`, () => {
259
- expect(isReviewableLockedPath(`.intentic\\capabilities.json`)).toBe(true);
260
- expect(isReviewableLockedPath(`./.intentic/capabilities.json`)).toBe(true);
270
+ expect(isReviewableLockedPath(`.intentic\\config\\capabilities.json`)).toBe(true);
271
+ expect(isReviewableLockedPath(`./.intentic/config/capabilities.json`)).toBe(true);
261
272
  });
262
273
 
263
274
  it(`stays a strict subset of the lock`, () => {
@@ -288,16 +299,16 @@ describe(`VERSIONED_STATE_PATHS`, () => {
288
299
  * megabytes. Tracking any of them buries the owner's code review under machine noise. */
289
300
  it(`leaves the ledgers and the bulk out even though they travel`, () => {
290
301
  for (const path of [
291
- `.intentic/workflow-runs.json`,
302
+ `.intentic/records/workflow-runs.json`,
292
303
  // Split out of the tracked automations manifest precisely so a fire stops dirtying it — the one
293
304
  // entry here that would be a REGRESSION rather than an oversight if it ever went tracked.
294
- `.intentic/automation-runs.json`,
295
- `.intentic/loops.json`,
296
- `.intentic/thread-sessions.json`,
297
- `.intentic/extension-usage.json`,
298
- `.intentic/webchat-installs.json`,
299
- `.intentic/sessions/claude/`,
300
- `.intentic/artifacts/`,
305
+ `.intentic/records/automation-runs.json`,
306
+ `.intentic/records/loops.json`,
307
+ `.intentic/records/thread-sessions.json`,
308
+ `.intentic/records/extension-usage.json`,
309
+ `.intentic/records/webchat-installs.json`,
310
+ `.intentic/records/sessions/claude/`,
311
+ `.intentic/records/artifacts/`,
301
312
  ]) {
302
313
  expect([path, VERSIONED_STATE_PATHS.includes(path)]).toEqual([path, false]);
303
314
  }
@@ -307,43 +318,43 @@ describe(`VERSIONED_STATE_PATHS`, () => {
307
318
  * consequence of editing the table above — the review the flag itself exists to force. */
308
319
  it(`tracks exactly the configuration slice plus the agent's own authored output`, () => {
309
320
  expect(VERSIONED_STATE_PATHS.toSorted()).toEqual([
310
- `.intentic/automations.json`,
321
+ `.intentic/config/automations.json`,
311
322
  /* The connections themselves, and the entry that took the longest to earn its place — it was classed
312
323
  * `secret` on the strength of holding each capability's credential, which stopped being true when the
313
324
  * vault took the values out and left the shape behind. Connecting a deployment orchestrator, or
314
325
  * granting a connected computer shell and screen control, is the largest change made to what this
315
326
  * sandbox can DO, and it used to leave no diff. */
316
- `.intentic/capabilities.json`,
317
- `.intentic/capability-dismissals.json`,
327
+ `.intentic/config/capabilities.json`,
328
+ `.intentic/config/capability-dismissals.json`,
318
329
  /* The two entries the AGENT authors on its own initiative, and the reason `versioned` is not read as
319
330
  * config-only. Both are the sandbox acting outward — a draft publishes words under the owner's name,
320
331
  * a workspace extension is code that runs in the app and can serve HTTP with the workspace under
321
332
  * node:fs — and both used to reach that far with no diff anywhere. Kept rather than consumed, one
322
333
  * small file at a time, so tracking them yields a record instead of churn. */
323
- `.intentic/drafts/`,
324
- `.intentic/environment.Dockerfile`,
325
- `.intentic/environment.custom.Dockerfile`,
326
- `.intentic/environment.d/`,
327
- `.intentic/extension-enablement.json`,
334
+ `.intentic/config/drafts/`,
335
+ `.intentic/config/environment.Dockerfile`,
336
+ `.intentic/config/environment.custom.Dockerfile`,
337
+ `.intentic/config/environment.d/`,
338
+ `.intentic/config/extension-enablement.json`,
328
339
  /* Its twin, and the pair is the argument: the SWITCH was already tracked while the configuration
329
340
  * behind it was not, so a commit could record turning an extension on and say nothing about what it
330
341
  * was told to do. Tracked once its declared-secret values moved to the vault. */
331
- `.intentic/extension-settings.json`,
342
+ `.intentic/config/extension-settings.json`,
332
343
  // The owner's per-extension update posture (notify / agent / auto) — a standing decision about
333
344
  // what may run unattended, which is exactly the kind of edit worth a line in `git log`.
334
- `.intentic/extension-update-policy.json`,
335
- `.intentic/loop-designs.json`,
336
- `.intentic/personas.json`,
345
+ `.intentic/config/extension-update-policy.json`,
346
+ `.intentic/config/loop-designs.json`,
347
+ `.intentic/config/personas.json`,
337
348
  // A persona's own kit — the prompt it runs on and the skills only its turns reach. Tracked for the
338
349
  // reason its card is, one step further: this is the text that decides how that persona behaves.
339
- `.intentic/personas/`,
340
- `.intentic/settings.json`,
350
+ `.intentic/config/personas/`,
351
+ `.intentic/config/settings.json`,
341
352
  // The skills the owner wrote. Tracked for the reason the rules in settings.json are: text that
342
353
  // changes how the agent behaves is worth a diff and a line in `git log`.
343
- `.intentic/skills/`,
344
- `.intentic/templates.json`,
345
- `.intentic/workflows.json`,
346
- `.intentic/workspace-extensions/`,
354
+ `.intentic/config/skills/`,
355
+ `.intentic/config/templates.json`,
356
+ `.intentic/config/workflows.json`,
357
+ `.intentic/config/workspace-extensions/`,
347
358
  ]);
348
359
  });
349
360
 
@@ -351,8 +362,8 @@ describe(`VERSIONED_STATE_PATHS`, () => {
351
362
  * the switch was tracked and the thing it switched was not, so a commit could record turning on an extension
352
363
  * whose code nobody else could read — and a workspace extension has no install moment to review at instead. */
353
364
  it(`tracks a workspace extension's code, not just the switch that enables it`, () => {
354
- expect(VERSIONED_STATE_PATHS).toContain(`.intentic/extension-enablement.json`);
355
- expect(VERSIONED_STATE_PATHS).toContain(`.intentic/workspace-extensions/`);
365
+ expect(VERSIONED_STATE_PATHS).toContain(`.intentic/config/extension-enablement.json`);
366
+ expect(VERSIONED_STATE_PATHS).toContain(`.intentic/config/workspace-extensions/`);
356
367
  });
357
368
 
358
369
  /* A QUEUE IS A LEDGER, which is the distinction the two entries above turn on. A draft is kept after it
@@ -361,13 +372,13 @@ describe(`VERSIONED_STATE_PATHS`, () => {
361
372
  * a decision whose outcome lives elsewhere. Same for the staged docs: publishing copies them into the repo,
362
373
  * where they are tracked as ordinary content, so tracking the staging tree too would double every page. */
363
374
  it(`leaves the consumed queues and the staging trees out even though they are authored`, () => {
364
- for (const path of [`.intentic/approvals/`, `.intentic/docs/`]) {
375
+ for (const path of [`.intentic/records/approvals/`, `.intentic/config/docs/`]) {
365
376
  expect([path, VERSIONED_STATE_PATHS.includes(path)]).toEqual([path, false]);
366
377
  }
367
378
  // Both still reach workspace search — searchability is a property of the content, not of tracking.
368
- for (const path of [`.intentic/approvals/`, `.intentic/docs/`]) {
379
+ for (const path of [`.intentic/records/approvals/`, `.intentic/config/docs/`]) {
369
380
  const entry = WORKSPACE_STATE_FILES.find((file) => file.path === path);
370
- expect([path, entry?.versioned === true || entry?.authored === true]).toEqual([path, path === `.intentic/docs/`]);
381
+ expect([path, entry?.versioned === true || entry?.authored === true]).toEqual([path, path === `.intentic/config/docs/`]);
371
382
  }
372
383
  });
373
384
 
@@ -375,7 +386,143 @@ describe(`VERSIONED_STATE_PATHS`, () => {
375
386
  * whatever base image this container happens to be on. Tracking it would put a rewritten-at-startup file in
376
387
  * front of the owner as a change they made. */
377
388
  it(`tracks the environment overlay's source but not its composed output`, () => {
378
- expect(VERSIONED_STATE_PATHS).toContain(`.intentic/environment.custom.Dockerfile`);
379
- expect(VERSIONED_STATE_PATHS).not.toContain(`.intentic/environment.approved.Dockerfile`);
389
+ expect(VERSIONED_STATE_PATHS).toContain(`.intentic/config/environment.custom.Dockerfile`);
390
+ expect(VERSIONED_STATE_PATHS).not.toContain(`.intentic/local/environment.approved.Dockerfile`);
391
+ });
392
+ });
393
+
394
+ /* THE GROUPING, AND THE INVARIANT THAT LETS IT BE DERIVED.
395
+ *
396
+ * `stateGroupOf` reads three existing answers instead of adding a fourth, which is only sound while those answers
397
+ * nest: reviewed and authored entries must all be `carry`, so "did a person write this" never has to be asked of
398
+ * a credential. Nothing in the type system says so — `versioned: true` on a `secret` entry compiles — and the
399
+ * failure would be quiet in the worst way: the entry would group as `secrets` (correctly kept out of the backup)
400
+ * while the git exclude and the search floor, which read `versioned` directly, went on tracking and indexing it.
401
+ * A credential in the owner's diff and in the search index, from one plausible-looking flag. */
402
+ describe(`state groups`, () => {
403
+ const GROUPS: readonly StateGroup[] = [`config`, `records`, `local`, `identity`, `secrets`];
404
+
405
+ it(`puts every entry in exactly one group, and the groups add back up to the table`, () => {
406
+ const grouped = GROUPS.flatMap((group) => stateGroupPaths(group));
407
+ expect(grouped.toSorted()).toEqual(WORKSPACE_STATE_FILES.map((file) => file.path).toSorted());
408
+ expect(new Set(grouped).size).toBe(WORKSPACE_STATE_FILES.length);
409
+ });
410
+
411
+ it(`keeps everything a person writes inside the class that travels`, () => {
412
+ for (const file of WORKSPACE_STATE_FILES) {
413
+ if (file.versioned === true || file.authored === true) {
414
+ expect(file.portability, `${file.path} is authored or reviewed, so it must be carry`).toBe(`carry`);
415
+ }
416
+ }
417
+ });
418
+
419
+ it(`agrees with the two lists that were derived before it`, () => {
420
+ // The git exclude reads `versioned` and the search floor reads `versioned || authored`. Both must land
421
+ // inside `config`, or the folder a rule points at stops matching the rule.
422
+ const written = stateGroupPaths(`config`);
423
+ for (const path of VERSIONED_STATE_PATHS) {
424
+ expect([path, written.includes(path)]).toEqual([path, true]);
425
+ }
426
+ expect(SEARCHABLE_STATE_PATHS.toSorted()).toEqual(written.toSorted());
427
+ });
428
+
429
+ it(`sorts the entries a browsing owner would recognise`, () => {
430
+ expect(stateGroupPaths(`config`)).toContain(`.intentic/config/personas/`);
431
+ expect(stateGroupPaths(`config`)).toContain(`.intentic/config/drafts/`);
432
+ expect(stateGroupPaths(`records`)).toContain(`.intentic/records/sessions/claude/`);
433
+ expect(stateGroupPaths(`records`)).toContain(`.intentic/records/artifacts/`);
434
+ expect(stateGroupPaths(`local`)).toContain(`.intentic/local/cache/`);
435
+ expect(stateGroupPaths(`local`)).toContain(`.intentic/local/browser/`);
436
+ expect(stateGroupPaths(`identity`)).toContain(`.intentic/identity/owner.json`);
437
+ expect(stateGroupPaths(`secrets`)).toContain(`.intentic/secrets/auth/`);
438
+ });
439
+
440
+ /* THE LAYOUT GUARD, and the reason the folders can carry the rules at all.
441
+ *
442
+ * Five prefixes replaced five hand-kept path lists — the git exclude, the search allow-list, the sync backup,
443
+ * the watcher skip, the export bundle — and every one of them is now only as true as the claim that an entry
444
+ * physically SITS in the folder its class puts it in. Nothing else checks that: the paths are literals, and a
445
+ * credential typed into `config/` by mistake would compile, track in git, index in search and copy to the
446
+ * owner's laptop, with each of those rules behaving exactly as designed. This is the one assertion standing
447
+ * between a mistyped literal and that. */
448
+ it(`puts every entry physically inside its own group's folder`, () => {
449
+ for (const file of WORKSPACE_STATE_FILES) {
450
+ const expected = `${STATE_GROUP_DIR[stateGroupOf(file)]}/`;
451
+ expect(file.path.startsWith(expected), `${file.path} should sit under ${expected}`).toBe(true);
452
+ }
453
+ });
454
+
455
+ /* The browser profiles' path is copied into @intentic/workspace-ignore (isBrowserProfilePath), which cannot
456
+ * import this package — it is the browser-safe half, deliberately free of zod and the contract surface, so
457
+ * the platform's web bundle can take it. This is the guard that keeps the copy honest. If it fails, the
458
+ * profiles moved and `BROWSER_PROFILE_GROUP` in _sandbox/workspace-ignore/src/constants.ts must move too, or
459
+ * the tree will start eagerly walking a Chromium user-data dir and the watcher will report its churn. */
460
+ it(`keeps the browser profiles where workspace-ignore's copy of the path expects them`, () => {
461
+ expect(WORKSPACE_STATE_FILES.map((file) => file.path)).toContain(`.intentic/local/browser/`);
462
+ });
463
+
464
+ it(`gives each group a folder of its own, all directly under the state dir`, () => {
465
+ const dirs = GROUPS.map((group) => STATE_GROUP_DIR[group]);
466
+ expect(new Set(dirs).size).toBe(GROUPS.length);
467
+ for (const dir of dirs) {
468
+ expect(dir.startsWith(`${STATE_DIR}/`)).toBe(true);
469
+ expect(dir.slice(`${STATE_DIR}/`.length).includes(`/`)).toBe(false);
470
+ }
471
+ });
472
+
473
+ it(`reads the group off the class, not off the name`, () => {
474
+ const entry = (path: string) => WORKSPACE_STATE_FILES.find((file) => file.path === path)!;
475
+ expect(stateGroupOf(entry(`.intentic/config/settings.json`))).toBe(`config`);
476
+ expect(stateGroupOf(entry(`.intentic/records/loops.json`))).toBe(`records`);
477
+ expect(stateGroupOf(entry(`.intentic/local/tmp/`))).toBe(`local`);
478
+ expect(stateGroupOf(entry(`.intentic/identity/members.json`))).toBe(`identity`);
479
+ expect(stateGroupOf(entry(`.intentic/secrets/ci.json`))).toBe(`secrets`);
480
+ });
481
+ });
482
+
483
+ /* WHAT THE OWNER'S MACHINE KEEPS. The sync ignored the whole state dir, so losing a sandbox lost every persona,
484
+ * skill, automation, draft and transcript in it — a backup that held only the source tree. These pin the two
485
+ * halves of the fix: the slice that now comes down, and the credentials that still must not. */
486
+ describe(`BACKED_UP_STATE_PATHS`, () => {
487
+ it(`splits the table in two with nothing falling between`, () => {
488
+ expect([...BACKED_UP_STATE_PATHS, ...UNBACKED_STATE_PATHS].toSorted()).toEqual(WORKSPACE_STATE_FILES.map((file) => file.path).toSorted());
489
+ for (const path of BACKED_UP_STATE_PATHS) {
490
+ expect([path, UNBACKED_STATE_PATHS.includes(path)]).toEqual([path, false]);
491
+ }
492
+ });
493
+
494
+ it(`copies down everything a person wrote and everything that happened`, () => {
495
+ for (const path of [...stateGroupPaths(`config`), ...stateGroupPaths(`records`)]) {
496
+ expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, true]);
497
+ }
498
+ });
499
+
500
+ it(`never copies a credential, whatever else the entry is`, () => {
501
+ for (const path of stateGroupPaths(`secrets`)) {
502
+ expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, false]);
503
+ }
504
+ });
505
+
506
+ it(`leaves the rebuildable bulk behind — it is size, not secrecy`, () => {
507
+ for (const path of stateGroupPaths(`local`)) {
508
+ expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, false]);
509
+ }
510
+ });
511
+
512
+ /* The distinction `portability` alone cannot draw, and the reason the `backup` flag exists. Ownership records
513
+ * may never RESTORE into another sandbox — that is what `identity` means, and members.json's own entry argues
514
+ * why. None of that stops the owner keeping a copy of who could drive their own machine. */
515
+ it(`copies the ownership records that may never travel`, () => {
516
+ for (const path of [`.intentic/identity/owner.json`, `.intentic/identity/members.json`, `.intentic/identity/workspace.json`]) {
517
+ expect([path, BACKED_UP_STATE_PATHS.includes(path)]).toEqual([path, true]);
518
+ }
519
+ });
520
+
521
+ it(`still withholds the tokens that authenticate against this sandbox`, () => {
522
+ expect(BACKED_UP_STATE_PATHS).not.toContain(`.intentic/identity/control-tokens.json`);
523
+ // And it is the ONLY entry that opts out by hand — everything else follows from its class.
524
+ expect(WORKSPACE_STATE_FILES.filter((file) => file.backup === false).map((file) => file.path)).toEqual([
525
+ `.intentic/identity/control-tokens.json`,
526
+ ]);
380
527
  });
381
528
  });