graphlin 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +465 -0
  8. package/docs/visualizer-views.md +199 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +74 -9
  34. package/plugin.json +4 -2
  35. package/runtime/core/evidence.mjs +43 -9
  36. package/runtime/core/graph.mjs +11 -6
  37. package/runtime/core/privacy.mjs +1 -0
  38. package/runtime/daemon/auth.mjs +7 -3
  39. package/runtime/daemon/diagnostics.mjs +1 -1
  40. package/runtime/daemon/extension-api.mjs +203 -0
  41. package/runtime/daemon/lineage.mjs +70 -0
  42. package/runtime/daemon/manager.mjs +9 -6
  43. package/runtime/daemon/model-api.mjs +728 -0
  44. package/runtime/daemon/model-persistence.mjs +220 -0
  45. package/runtime/daemon/server.mjs +70 -12
  46. package/runtime/daemon/settings.mjs +11 -3
  47. package/runtime/decisions/broker.mjs +349 -0
  48. package/runtime/decisions/contracts.mjs +179 -0
  49. package/runtime/decisions/evaluation.mjs +305 -0
  50. package/runtime/decisions/faults.mjs +32 -0
  51. package/runtime/decisions/index.mjs +818 -0
  52. package/runtime/decisions/profiles.mjs +93 -0
  53. package/runtime/decisions/questions.mjs +268 -0
  54. package/runtime/discovery/index.mjs +2 -0
  55. package/runtime/discovery/inventory.mjs +160 -0
  56. package/runtime/discovery/parser.mjs +40 -0
  57. package/runtime/discovery/structure.mjs +232 -0
  58. package/runtime/extensions/contracts.mjs +59 -0
  59. package/runtime/extensions/frame.mjs +64 -0
  60. package/runtime/extensions/index.mjs +9 -0
  61. package/runtime/extensions/manifest.mjs +95 -0
  62. package/runtime/extensions/packages.mjs +222 -0
  63. package/runtime/extensions/profiles.mjs +36 -0
  64. package/runtime/extensions/projection.mjs +130 -0
  65. package/runtime/extensions/registry.mjs +285 -0
  66. package/runtime/extensions/scene.mjs +105 -0
  67. package/runtime/extensions/sdk.d.ts +205 -0
  68. package/runtime/extensions/sdk.mjs +88 -0
  69. package/runtime/jev/index.mjs +13 -777
  70. package/runtime/jev/provider.mjs +101 -0
  71. package/runtime/jev/questions.mjs +16 -258
  72. package/runtime/jev/wire.mjs +17 -25
  73. package/runtime/model/changes.mjs +42 -0
  74. package/runtime/model/history.mjs +124 -0
  75. package/runtime/model/index.mjs +2 -0
  76. package/runtime/model/project-model.mjs +889 -0
  77. package/runtime/model/records.mjs +239 -0
  78. package/runtime/pipeline.mjs +127 -48
  79. package/runtime/platform.mjs +254 -0
  80. package/runtime/visualizers/blocks.mjs +5 -0
  81. package/runtime/visualizers/c4.mjs +52 -0
  82. package/runtime/visualizers/changes.mjs +24 -0
  83. package/runtime/visualizers/code.mjs +5 -0
  84. package/runtime/visualizers/index.mjs +23 -0
  85. package/runtime/visualizers/structure.mjs +120 -0
  86. package/runtime/visualizers/timeline.mjs +66 -0
  87. package/runtime/web/app.js +225 -63
  88. package/runtime/web/extension-frame.js +128 -0
  89. package/runtime/web/index.html +36 -1
  90. package/runtime/web/model-client.js +162 -0
  91. package/runtime/web/platform.js +337 -0
  92. package/runtime/web/scene.js +111 -0
  93. package/runtime/web/style.css +49 -0
  94. package/schemas/graph.schema.json +4 -1
  95. package/scripts/arguments.mjs +5 -1
  96. package/scripts/build-packages.mjs +6 -2
  97. package/scripts/control.mjs +1 -1
  98. package/scripts/daemon.mjs +2 -1
  99. package/scripts/extensions.mjs +44 -0
  100. package/scripts/graphlin.mjs +23 -3
  101. package/scripts/onboarding.mjs +10 -3
  102. package/scripts/validate-packages.mjs +54 -8
@@ -0,0 +1,553 @@
1
+ # Authoring a Graphlin visualizer
2
+
3
+ An extension receives a policy-filtered model and returns a small scene, or
4
+ renders a custom view in an isolated browser frame. It never runs inside the
5
+ Graphlin daemon. It needs no collector, agent plugin, API key, source access, or
6
+ runtime dependency installation.
7
+
8
+ Extension API **1**, model schema **2**, and scene schema **1** are separate
9
+ versions. The browser SDK is `runtime/extensions/sdk.mjs`; its public types are
10
+ in `runtime/extensions/sdk.d.ts`. The runtime entry is
11
+ `runtime/extensions/index.mjs`. Package authors can subscribe to the browser
12
+ protocol directly, so a build tool is optional.
13
+
14
+ The public package export `graphlin/extensions/sdk` resolves to that browser
15
+ entry and its types; `graphlin/extensions/scene` exposes scene validation alone.
16
+ Bundle these imports into the single executable asset.
17
+
18
+ ## Create an independent package
19
+
20
+ Create a directory containing only these package files:
21
+
22
+ ```text
23
+ my-c4/
24
+ graphlin.extension.json
25
+ package.json
26
+ dist/
27
+ visualizer.js
28
+ profiles/
29
+ c4.json
30
+ ```
31
+
32
+ The optional `README.md` and `LICENSE` files are also allowed. Other files must
33
+ be declared JS/JSON assets. Keep authoring sources, environment files,
34
+ dependencies, maps, and build tools outside this install directory. Symlinks,
35
+ hard links, special files, undeclared files, and `node_modules` are rejected.
36
+
37
+ Save this as `dist/visualizer.js`. This small C4 projection reads explicitly
38
+ typed, supported application/datastore interpretations. A class, directory,
39
+ generic analysis answer, or label does not create an application boundary.
40
+ If the model has no supported interpretations, it abstains.
41
+
42
+ ```js
43
+ window.addEventListener('graphlin:connect', ({ detail: { port } }) => {
44
+ const kinds = { application: 'service', container: 'service', datastore: 'datastore' };
45
+ port.onmessage = ({ data: message }) => {
46
+ if (message.type === 'graphlin:dispose') { port.close(); return; }
47
+ if (message.type !== 'graphlin:project') return;
48
+ const model = message.model;
49
+ const byId = new Map(model.entities.map(entity => [entity.id, entity]));
50
+ const chosen = model.interpretations.filter(value =>
51
+ Object.hasOwn(kinds, value.kind) &&
52
+ value.validity === 'current' && value.support === 'supported' &&
53
+ value.classification === 'accepted' && value.sourceRefs.length &&
54
+ value.entityIds.length && value.entityIds.every(id => byId.get(id)?.validity === 'current')
55
+ ).slice(0, 64);
56
+ const groups = chosen.map(value => ({
57
+ id: `boundary-${value.id}`, entityIds: value.entityIds,
58
+ membershipId: value.id, label: value.label,
59
+ kind: kinds[value.kind], parentId: null, collapsed: true
60
+ }));
61
+ const owner = new Map(groups.flatMap(group => group.entityIds.map(id => [id, group.id])));
62
+ const relationKinds = new Set([
63
+ 'calls', 'reads', 'writes', 'publishes', 'consumes', 'depends_on'
64
+ ]);
65
+ const edges = model.relations.filter(relation =>
66
+ owner.has(relation.source) && owner.has(relation.target) &&
67
+ owner.get(relation.source) !== owner.get(relation.target) &&
68
+ relation.validity === 'current' && relationKinds.has(relation.kind)
69
+ ).slice(0, 256).map(relation => ({
70
+ id: `edge-${relation.id}`,
71
+ source: owner.get(relation.source),
72
+ target: owner.get(relation.target),
73
+ kind: relation.kind,
74
+ relationIds: [relation.id],
75
+ count: 1
76
+ }));
77
+ port.postMessage({
78
+ type: 'graphlin:scene', apiVersion: 1,
79
+ instanceId: message.instanceId, projectId: message.projectId,
80
+ revision: message.revision, viewEpoch: message.viewEpoch,
81
+ requestId: message.requestId,
82
+ scene: {
83
+ sceneVersion: 1, nodes: [], edges, groups,
84
+ coverage: {
85
+ shown: owner.size, total: model.entities.length,
86
+ truncated: model.entities.length > owner.size,
87
+ label: groups.length ? 'Supported interpretations only' : 'Application boundaries unknown'
88
+ }
89
+ }
90
+ });
91
+ };
92
+ port.start();
93
+ });
94
+ ```
95
+
96
+ Save this as `profiles/c4.json`. The optional `interpretationKind` mapping
97
+ declares the meaning of a supported answer. The broker uses the selected
98
+ entity's core label for the resulting boundary, not the word `application`.
99
+ `unknown` cannot create a C4 boundary.
100
+
101
+ ```json
102
+ {
103
+ "id": "architecture",
104
+ "questions": [
105
+ {
106
+ "id": "role",
107
+ "kind": "choice",
108
+ "question": "Which role, if any, is supported for the selected entities by the supplied current model metadata? Choose unknown when the metadata does not establish a boundary.",
109
+ "options": ["application", "datastore", "unknown"],
110
+ "interpretationKind": "selected-choice"
111
+ }
112
+ ],
113
+ "selectors": {
114
+ "fields": ["entities", "relations"],
115
+ "candidateIds": []
116
+ }
117
+ }
118
+ ```
119
+
120
+ This profile runs only after explicit host activation, profile approval, and
121
+ source-transmission consent. Opening or rendering the extension never runs it.
122
+ Metadata often cannot establish an architectural boundary; the default C4
123
+ view and this example remain unknown until core admits a supported mapping.
124
+
125
+ Save `package.json`:
126
+
127
+ ```json
128
+ {
129
+ "name": "my-graphlin-c4",
130
+ "version": "1.0.0",
131
+ "files": ["graphlin.extension.json", "dist/visualizer.js", "profiles/c4.json"]
132
+ }
133
+ ```
134
+
135
+ Generate the manifest from the final asset bytes. Run this in `my-c4`:
136
+
137
+ ```sh
138
+ node --input-type=module <<'JS'
139
+ import { readFile, writeFile } from 'node:fs/promises';
140
+ import { createHash } from 'node:crypto';
141
+ const entry = 'dist/visualizer.js';
142
+ const assets = {};
143
+ for (const name of [entry, 'profiles/c4.json']) {
144
+ assets[name] = 'sha256-' + createHash('sha256').update(await readFile(name)).digest('hex');
145
+ }
146
+ await writeFile('graphlin.extension.json', JSON.stringify({
147
+ manifestVersion: 1,
148
+ id: 'example.c4',
149
+ name: 'My C4 view',
150
+ version: '1.0.0',
151
+ graphlinApi: '1',
152
+ modelSchema: '2',
153
+ requiredFeatures: ['canonical-mappings', 'scene-groups'],
154
+ entry,
155
+ assets,
156
+ decisionProfiles: ['profiles/c4.json'],
157
+ views: ['applications'],
158
+ renderer: { kind: 'graphlin-scene', sceneVersion: '1' },
159
+ capabilities: ['model.read', 'selection.request', 'analysis.request']
160
+ }, null, 2));
161
+ JS
162
+ ```
163
+
164
+ The manifest filename and keys are exact. Unknown keys, capabilities, required
165
+ features, and incompatible versions fail closed. Asset paths are relative
166
+ without `./`, `..`, encoded characters, URL suffixes, or backslashes.
167
+
168
+ API 1 capabilities are `model.read`, `activity.read`, `history.read`,
169
+ `selection.request`, `inspection.request`, and `analysis.request`.
170
+ Supported required features are `containment`, `canonical-mappings`,
171
+ `scene-groups`, `activity`, and `checkpoints`. A feature name is a compatibility
172
+ requirement, not a grant.
173
+
174
+ The renderer is `{ "kind": "graphlin-scene", "sceneVersion": "1" }` or
175
+ `{ "kind": "custom" }`. There is exactly one executable `.js` asset: the entry.
176
+ Bundle any SDK or third-party library into that classic script. ES module
177
+ imports/exports, source maps, runtime dependencies, and external assets are not
178
+ supported. Additional declared `.json` assets are inert data supplied in the
179
+ `graphlin:connect` event.
180
+
181
+ ## Install, update, inspect, and remove
182
+
183
+ The parent CLI calls `runExtensions(args, { projectRoot, dataDir })` from
184
+ `scripts/extensions.mjs`. With that dispatch integrated, the commands are:
185
+
186
+ ```sh
187
+ graphlin extensions add ./my-c4
188
+ graphlin extensions list
189
+ graphlin extensions doctor
190
+ graphlin extensions dev ./my-c4
191
+ graphlin extensions add my-graphlin-c4@1.0.0
192
+ graphlin extensions update my-graphlin-c4@1.0.1
193
+ graphlin extensions remove example.c4
194
+ ```
195
+
196
+ `dev` requires an explicit directory, copies a development snapshot, and marks
197
+ it visibly in the catalogue. Rerunning it reloads the directory. For a live
198
+ authoring session, the integration may use
199
+ `registry.dev(directory, { watch: true, signal, onChange })`. Its returned
200
+ `close()` stops watching; `done` resolves when closed. Invalid intermediate
201
+ builds report an error and retain the installed version. Successful changes
202
+ have new digests, require new grants, and require frame replacement.
203
+
204
+ To produce an npm archive locally:
205
+
206
+ ```sh
207
+ npm pack --ignore-scripts
208
+ ```
209
+
210
+ Publishing is a separate, explicitly authorized author operation. Graphlin
211
+ remote installation accepts only an exact `name@version` or
212
+ `@scope/name@version`. It runs `npm pack --ignore-scripts` in a private temporary
213
+ directory with a clean npm configuration, then validates tar contents in
214
+ memory. No lifecycle scripts or dependency installation are run.
215
+
216
+ Archive validation rejects path traversal, links, device/sparse files, PAX/GNU
217
+ extended headers, duplicate or case-colliding paths, corrupt checksums,
218
+ truncation, and oversized contents. Packages requiring extended headers should
219
+ use shorter file paths. Package name/version must match the requested identity.
220
+
221
+ Limits are 32 assets, 128 package entries, 2 MiB per file, 8 MiB package content,
222
+ and 12 MiB compressed or expanded archive size. Hashes are SHA-256 hex strings
223
+ prefixed with `sha256-`. The bundle digest also covers the canonical manifest,
224
+ so changes to capabilities, profiles, or labels create a different identity.
225
+
226
+ Installed assets live under the private data directory at
227
+ `extensions/bundles/<id>/<version>/<digest>/`. The runtime never edits a stored
228
+ bundle. An atomic catalogue write activates a verified bundle. Failed validation,
229
+ cancelled transport, or a failed catalogue write leaves the active version and
230
+ its grants intact. Old bundle bytes are retained for recovery. Removal atomically
231
+ removes catalogue access and grants; retained bytes are unreachable through
232
+ the registry. The authoring runtime currently has no automatic bundle garbage
233
+ collection.
234
+
235
+ ## Approve data explicitly
236
+
237
+ Installation alone delivers no project data. The authenticated host owns approval:
238
+
239
+ ```js
240
+ await registry.grant('example.c4', {
241
+ digest: installed.digest,
242
+ fields: ['entities', 'relations', 'interpretations', 'coverage'],
243
+ history: false,
244
+ approved: true,
245
+ profiles: ['architecture']
246
+ });
247
+ ```
248
+
249
+ Allowed field groups are `entities`, `relations`, `interpretations`, `activity`,
250
+ `coverage`, `sessions`, and `checkpoints`. Each needs the corresponding declared
251
+ read capability. `checkpoints` requires `history: true`, and history requires
252
+ `history.read`. A grant can optionally approve named `profiles`.
253
+ HTTP session selectors also require history, because they can address earlier work.
254
+
255
+ Approval is bound to project, extension ID, exact bundle digest, fields, history,
256
+ and profiles. An update to different bytes clears earlier approvals across
257
+ projects. Rolling back does not restore approval automatically. An unchanged
258
+ bundle may keep its existing grant. `revoke(id)` stops current-project access;
259
+ removal stops access across projects.
260
+
261
+ For every delivery, the broker calls:
262
+
263
+ ```js
264
+ const grant = await registry.getGrant(extensionId);
265
+ const projected = getExtensionDataProjection(currentPolicySnapshot, grant);
266
+ if (projected === null) {
267
+ // Destroy the frame and purge host-controlled scenes/caches.
268
+ }
269
+ ```
270
+
271
+ `getExtensionDataProjection(snapshot, grant)` is pure: it never reads storage,
272
+ mutates a model, or grants authority. It returns `null` for invalid, denied,
273
+ unapproved, or cross-project grants. A copied grant object is not a token; the
274
+ broker must get the current grant again and compare the mounted digest before
275
+ every delivery. Concurrent updates/revocations must invalidate in-flight work.
276
+
277
+ The input snapshot must already reflect current core policy. Reapply that
278
+ policy to history too. Mark historical inputs with `replay: true` or
279
+ `checkpointId` and check `grant.history`; the pure function cannot distinguish
280
+ an unmarked old snapshot from a live one.
281
+
282
+ The output shape is:
283
+
284
+ ```text
285
+ {
286
+ schemaVersion: 2, projectId, revision, sequence,
287
+ entities: [{
288
+ id, label, kind, parentId, artifactId?, qualifiedName?,
289
+ sourceRefs, basis, validity, classification
290
+ }],
291
+ relations: [{id, source, target, kind, basis, validity, sourceRefs}],
292
+ interpretations, activity, coverage, sessions, checkpoints
293
+ }
294
+ ```
295
+
296
+ Unapproved collections are empty. Relations and interpretations only reference
297
+ delivered entities. Activity-only visualizers are supported. Every nested
298
+ record is built from allowed fields, including coverage, interpretation
299
+ namespaces, source references, sessions, and checkpoint metadata. Source
300
+ references contain bounded IDs, hashes, generations, and line numbers, never
301
+ source text.
302
+
303
+ No API 1 grant includes source, excerpts, prompts, transcripts, absolute paths,
304
+ raw hook bodies, credentials, arbitrary object namespaces, or arbitrary
305
+ inspection responses. Labels are bounded and filtered for secrets and absolute
306
+ locators. Only the host inspector can display separately authorized evidence.
307
+ Parsed structure, public intent, interpretation, and execution remain distinct;
308
+ an extension cannot upgrade an attempted operation into observed success.
309
+
310
+ ## Validate scenes and messages
311
+
312
+ Use `validateScene(scene, { model: deliveredProjection })` in the host.
313
+ `validateScene(scene)` checks structure alone and is useful for author tests;
314
+ it is insufficient at the data boundary.
315
+
316
+ A scene has `sceneVersion: 1`, `nodes`, `groups`, `edges`, and optional `coverage`.
317
+ Nodes have `id`, `entityId`, `label`, and `kind`. Groups have `id`, `entityIds`,
318
+ and `label`; optional `membershipId` is an opaque membership reference.
319
+ Both accept `parentId`, finite layout hints, and finite style/shape tokens.
320
+ Parent IDs always refer to scene groups. Group cycles and missing parents fail.
321
+
322
+ Edges have `id`, `source`, `target`, and `kind`. Under model validation,
323
+ `relationIds` must identify delivered relations of that exact kind connecting
324
+ the represented endpoint entities. A group's `entityIds` is its bounded
325
+ membership set. `count`, when present, equals the supplied relation count.
326
+ Never combine calls, reads, and writes into a stronger relation. The renderer
327
+ can lay out a legacy-compatible node kind while retaining group parent links.
328
+
329
+ Limits are 256 nodes, 64 groups, 768 edges, 256 members per mapping, 240 label
330
+ characters, and 1 MiB per serialized scene. Coordinates must be finite within
331
+ ±100,000; dimensions must be positive and at most 20,000. Arbitrary HTML, SVG,
332
+ CSS, URLs, event handlers, and unknown fields are rejected. Styles are tokens
333
+ such as `default`, `tentative`, `stale`, `active`, and `discovered`, not CSS.
334
+ The complete token sets are exported from `sdk.mjs`.
335
+
336
+ Each asynchronous message carries this context:
337
+
338
+ ```js
339
+ {
340
+ apiVersion: 1,
341
+ instanceId, projectId, revision, viewEpoch, requestId
342
+ }
343
+ ```
344
+
345
+ The SDK supports:
346
+
347
+ | Type | Payload |
348
+ | --- | --- |
349
+ | `graphlin:project` | `model`, optional `settings`, optional `selection` |
350
+ | `graphlin:scene` | `scene` |
351
+ | `graphlin:status` | `status`: `ready`, `busy`, `empty`, or `error`; `itemCount`: integer 0–20,000 |
352
+ | `graphlin:select` | `selection`: exactly one `entityId`, `relationId`, or `activityId` |
353
+ | `graphlin:error` | Fixed diagnostic `code` |
354
+ | `graphlin:dispose` | No payload |
355
+
356
+ The host calls `validateMessage(message, { model: deliveredProjection, context })`
357
+ before handling responses. Supplying `context` verifies instance, project,
358
+ revision, view epoch, request, and API version. Selection validation requires
359
+ the granted projection and rejects targets absent from it. The host additionally
360
+ checks the active `selection.request`/`inspection.request` capability, current
361
+ grant, message rate, outstanding requests, and projection deadline.
362
+
363
+ When bundled, `connectExtension({ project, mount, dispose })` handles the basic
364
+ project/scene lifecycle and discards superseded asynchronous results. A custom
365
+ renderer may update its DOM during `project` and return no scene, then post
366
+ bounded status and selection messages using the same protocol. Host-owned
367
+ controls, keyboard access, readable labels, reduced motion, and teardown remain
368
+ custom-renderer responsibilities.
369
+
370
+ ## Declare neutral analysis profiles
371
+
372
+ The C4 package above declares `analysis.request`,
373
+ `decisionProfiles: ["profiles/c4.json"]`, and the profile file's SHA-256 in
374
+ `assets`. Each file contains one profile. The SDK's `DecisionQuestion` and
375
+ `DecisionProfile` types describe the same validated fields.
376
+
377
+ Question kinds are `boolean`, `choice`, and `score` (0–1). Limits are 8 profiles,
378
+ 16 questions/profile, 400 characters/question, 2–16 choice options of at most
379
+ 80 characters, and 256 candidate IDs. Selectors may name only `entities`,
380
+ `relations`, and `interpretations`; an empty candidate list requests a bounded
381
+ host-selected scope. Source/path/URL fields, thresholds, and executable
382
+ callbacks are not part of the schema.
383
+
384
+ Questions may declare `interpretationKind` as `application`, `container`,
385
+ `component`, `system`, `external_system`, `actor`, `person`, `context`, or
386
+ `datastore`. A choice question may instead declare `selected-choice`, but
387
+ every option must then be one of those exact lowercase kinds or `unknown`.
388
+ Arbitrary choice labels cannot select a semantic kind. Without this field,
389
+ answers retain the generic `analysis-boolean`, `analysis-choice`, or
390
+ `analysis-score` kind and never implicitly create C4 boundaries.
391
+
392
+ For a fixed mapping, an independently answerable question can be:
393
+
394
+ ```json
395
+ {
396
+ "id": "application-supported",
397
+ "kind": "boolean",
398
+ "question": "Does the supplied current metadata support the selected entities as one application boundary?",
399
+ "interpretationKind": "application",
400
+ "interpretationLabel": "Order processing"
401
+ }
402
+ ```
403
+
404
+ `interpretationLabel` is optional, requires an interpretation mapping, and is
405
+ limited to 80 characters with no markup, controls, or URLs. Core also applies
406
+ its local secret and locator filters. If omitted, the broker uses the first
407
+ selected entity's filtered core label. An explicit label can describe a
408
+ selection containing several entities. The label never determines the kind
409
+ and is never treated as provider evidence.
410
+
411
+ Only a supported, accepted answer with exact current source references can
412
+ receive a semantic kind. Missing probabilities/confidence, insufficient
413
+ support, or selecting `unknown` records a generic unknown interpretation.
414
+ Contradicted answers also keep their generic kind. Fixed mappings express
415
+ the author's intended meaning; they cannot override core thresholds or grant
416
+ authority. Mapping or label changes alter the hashed bundle and require fresh
417
+ approval. Score descriptors remain neutral 0–1 questions; the broker maps them
418
+ to `['Low', 'High']` independently of these output semantics.
419
+
420
+ `getAssets()` returns validated profiles with namespace
421
+ `<extension-id>.<profile-id>`. Installation validates their structure and
422
+ integrity; it cannot establish the truth or neutrality of natural-language
423
+ questions. The parent decision broker separately approves profile activation,
424
+ checks the current project/digest/profile grant, restricts candidate IDs to the
425
+ delivered scope, and applies source consent and local filtering before any
426
+ provider request. It records namespaced interpretations. Projection, view
427
+ switching, and replay never implicitly activate analysis.
428
+
429
+ ## Parent integration contract
430
+
431
+ Initialize `await createExtensionRegistry({ dataDir, projectId })` outside the
432
+ synchronous hook path. Methods are asynchronous: `list`, `install`, `remove`,
433
+ `grant`, `revoke`, `getGrant`, `getAssets`, `doctor`, and `dev`.
434
+
435
+ Tests inject `transport(spec, { directory, signal })`, which returns tarball
436
+ bytes or a tarball path inside that private temporary directory. Production uses
437
+ the bounded npm pack transport. Injected transport is trusted host code and
438
+ does not become an extension manifest capability.
439
+
440
+ Pinned host routes:
441
+
442
+ ```text
443
+ GET /api/extensions
444
+ POST /api/extensions/grant
445
+ POST /api/extensions/revoke
446
+ POST /api/extensions/analysis
447
+ GET /api/extensions/data/<id>
448
+ GET /api/extensions/frame/<id>?nonce=<fresh>
449
+ ```
450
+
451
+ The parent owns all authentication, strict route/query parsing, the frame host
452
+ UI, grant prompts, selection/inspection, and live/replay subscriptions. Extension
453
+ frames are not authenticated API principals and receive no viewer credentials.
454
+
455
+ `runtime/daemon/extension-api.mjs` provides
456
+ `createExtensionAPI({ registry, getSnapshot, projectId, runAnalysis? })`, returning
457
+ `{ handle }`. Call `await handle(req, res, { viewerAuthorized })` after the
458
+ parent's existing Host/Origin and viewer-cookie checks, before a legacy
459
+ blanket rejection of query strings. It returns `true` for handled extension
460
+ routes and `false` for unrelated routes. External read bearer tokens and opaque
461
+ origins must never satisfy `viewerAuthorized`.
462
+
463
+ `getSnapshot({ scopeId?, sessionId?, checkpointId?, persistent: false })` must
464
+ return the current-policy snapshot synchronously, just as the model API does.
465
+ The helper accepts only `scope`, `session`, and `checkpoint` on data routes
466
+ and `nonce` on frame routes. Duplicate, empty, unknown, or malformed parameters
467
+ are rejected. It tags requested checkpoints as replay before applying the
468
+ grant. It applies every frame response header and removes an inherited
469
+ `X-Frame-Options: DENY` only when delivering a valid authorized frame document.
470
+
471
+ The grant body is `{ id, digest, fields, history, approved, profiles? }`;
472
+ revocation takes `{ id }`. The analysis body is
473
+ `{ id, digest, profileId, entityIds, revision }`. Analysis requires the current
474
+ profile/field grant, a declared profile, current revision, and candidates in
475
+ the granted projection and the profile's candidate selector. It accepts no
476
+ caller-supplied questions, paths, source, or URLs.
477
+
478
+ The injected `runAnalysis` callback receives
479
+ `{ projectId, extensionId, digest, profile, entityIds, revision, grant, signal }`.
480
+ It returns `{ status, requestId?, interpretationIds? }`, where status is
481
+ `accepted`, `pending`, `complete`, or `unavailable`. The HTTP response includes
482
+ only those fields, and only interpretation IDs already recorded under the
483
+ profile namespace and visible through the current grant. The helper rechecks
484
+ grants after asynchronous asset/provider operations and sends no stale result
485
+ after revocation. Provider errors become fixed public errors. The callback
486
+ still owns source consent, local filtering, evidence version checks, provider
487
+ budgets, interpretation recording, and shared-job ownership.
488
+
489
+ Construct a frame response with:
490
+
491
+ ```js
492
+ const installed = await registry.getAssets(extensionId);
493
+ const { body, headers } = createFrameDocument({ ...installed, nonce });
494
+ // Send every returned header and body; never serve body alone or as srcdoc.
495
+ ```
496
+
497
+ `getAssets(id)` returns `{ manifest, assets, digest, profiles, development }`.
498
+ An optional `{ digest }` checks a pinned current digest. Optional
499
+ `{ assetPath }` returns `{ bytes, contentType, digest, assetPath }` only for
500
+ that exact installed, hash-verified declared file. Never fall through to a
501
+ filesystem/static-server path. A missing install or mismatched digest fails.
502
+
503
+ `createFrameDocument` returns a response-level CSP containing
504
+ `sandbox allow-scripts`, no network sources, no forms, workers, child frames,
505
+ CSS, media, or evaluated code, and SHA-256 allowances for the exact trusted
506
+ prelude and bundled script. It sets no-store, no-referrer, and nosniff.
507
+ The frame must also use `sandbox="allow-scripts"` without `allow-same-origin`.
508
+ Its document remains sandboxed when opened directly.
509
+
510
+ Bootstrap uses a fresh unpredictable base64url nonce of 24–128 characters:
511
+
512
+ 1. Bind the installed digest, approved grant, instance, and nonce to this frame
513
+ document. The host must await its initial load.
514
+ 2. The host creates a `MessageChannel` and posts
515
+ `{ type: "graphlin:bootstrap", apiVersion: 1, nonce }` with exactly one port
516
+ to the expected frame window. An opaque-origin target requires `"*"`;
517
+ that does not authorize a different window.
518
+ 3. The prelude accepts only `event.source === parent`, the matching nonce,
519
+ one port, and its first bootstrap. It emits `graphlin:connect` with the port,
520
+ nonce, API version, and inert JSON assets, then sends
521
+ `{ type: "graphlin:ready", apiVersion: 1, nonce }` on the port.
522
+ 4. The host checks the ready nonce and current grant/digest before sending
523
+ projected model data. Each subsequent message is context-bound and bounded.
524
+ 5. On navigation, replacement, disposal, revocation, policy tightening, or
525
+ digest change, close old ports, cancel frame-owned work, clear revoked
526
+ scenes/caches, and bootstrap a new document only after approval. Shared
527
+ decision jobs retain their other authorized owners.
528
+
529
+ A sandboxed extension still reads the data the user grants it. CSP and opaque
530
+ origins are not a universal zero-disclosure guarantee, including frame
531
+ self-navigation. Revocation stops future delivery and clears host-controlled
532
+ copies; it cannot recall copied data. Iframes also do not guarantee hard CPU
533
+ or memory isolation. The parent browser integration must test actual browser
534
+ behavior, navigation replacement, direct opening, timeouts, and recovery before
535
+ enabling custom renderers.
536
+
537
+ ## Offline conformance checks
538
+
539
+ From the Graphlin checkout:
540
+
541
+ ```sh
542
+ npm test -- tests/extensions/*.test.mjs
543
+ npm run build
544
+ npm run check:packages
545
+ ```
546
+
547
+ The tests use original synthetic C4 data and injected registry transport. They
548
+ cover unknown features/capabilities, integrity, install rollback, cancellation,
549
+ unsafe paths/links/tar entries, digest-scoped grants, revocation, history,
550
+ allowlisted nested projection, scene mappings and bounds, nonce bootstrap,
551
+ custom status/selection, and SDK cancellation. They use no keys, live decision
552
+ provider, or private project. Browser isolation and npm-installed end-to-end
553
+ selection remain parent integration gates.