@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -0,0 +1,188 @@
1
+ import { createBabylonPortFactory } from './babylon-scene-port.mjs'
2
+ import { createGraphNavigator } from './navigator.mjs'
3
+
4
+ const activeCanvases = new WeakMap()
5
+ function unavailableHost(reason, dispose = () => {}) {
6
+ const unavailable = () => ({ status: 'unavailable' })
7
+ return Object.freeze({ status: 'unavailable', reason,
8
+ select: unavailable, selectEdge: unavailable, receiveSelection: unavailable,
9
+ focus: unavailable, expand: unavailable, openEvidence: unavailable, setQuestion: unavailable,
10
+ setLens: unavailable, update: unavailable, fit() {}, reset() {}, zoom() {}, pan() {},
11
+ snapshot: () => null, dispose, clear: dispose })
12
+ }
13
+
14
+ // The browser owns scheduling, input and engine lifetime; the reused port owns
15
+ // scene geometry. Graphics availability never controls the companion document.
16
+ export function mountGraphCanvas(canvas, { B, view, onIntent, onAvailability = () => {},
17
+ createEngine = element => new B.Engine(element, true, { preserveDrawingBuffer: false, stencil: true }),
18
+ } = {}) {
19
+ if (!canvas?.ownerDocument?.defaultView) throw new TypeError('Browser canvas required')
20
+ if (activeCanvases.has(canvas)) return unavailableHost('canvas-in-use')
21
+ const lease = {}
22
+ activeCanvases.set(canvas, lease)
23
+ const document = canvas.ownerDocument, window = document.defaultView
24
+ const listeners = [], cameras = new Set()
25
+ let engine, navigator, camera, frame = null, down = null, disposed = false, lost = false
26
+ let motion = window.matchMedia?.('(prefers-reduced-motion: reduce)')
27
+ const listen = (target, event, handler, options) => {
28
+ target.addEventListener(event, handler, options)
29
+ listeners.push(() => target.removeEventListener(event, handler, options))
30
+ }
31
+ const available = state => { try { onAvailability(state) } catch { /* Consumer notification. */ } }
32
+ function clearBuffer() {
33
+ // Force reallocation: assigning the same width can retain composited pixels.
34
+ const width = canvas.width
35
+ canvas.width = width === 0 ? 1 : 0
36
+ canvas.width = width
37
+ }
38
+ function present(result) {
39
+ if (result.status === 'empty') clearBuffer()
40
+ fit()
41
+ return result
42
+ }
43
+ function dispose() {
44
+ if (disposed) return
45
+ disposed = true
46
+ if (frame !== null) window.cancelAnimationFrame(frame)
47
+ frame = null
48
+ down = null
49
+ listeners.splice(0).reverse().forEach(remove => remove())
50
+ try { navigator?.dispose() } finally {
51
+ for (const item of cameras) item.detachControl()
52
+ cameras.clear()
53
+ try { engine?.dispose() } finally {
54
+ engine = camera = navigator = null
55
+ if (activeCanvases.get(canvas) === lease) {
56
+ activeCanvases.delete(canvas)
57
+ // Reset the owned drawing buffer synchronously, including the last
58
+ // composited frame, before a narrower view may be delivered.
59
+ clearBuffer()
60
+ }
61
+ }
62
+ }
63
+ }
64
+ function applyMotion() {
65
+ if (!camera) return
66
+ camera.inertia = motion?.matches ? 0 : .85
67
+ camera.panningInertia = motion?.matches ? 0 : .85
68
+ if (motion?.matches) stopInertia()
69
+ }
70
+ function stopInertia() {
71
+ if (!camera) return
72
+ for (const name of ['inertialAlphaOffset', 'inertialBetaOffset', 'inertialRadiusOffset', 'inertialPanningX', 'inertialPanningY']) camera[name] = 0
73
+ }
74
+ function fit() {
75
+ if (!camera || !navigator) return
76
+ const positions = camera.getScene().meshes.filter(mesh => mesh.isPickable && mesh.isEnabled()).map(mesh => mesh.position)
77
+ if (!positions.length) return
78
+ const min = new B.Vector3(Infinity, Infinity, Infinity), max = new B.Vector3(-Infinity, -Infinity, -Infinity)
79
+ for (const point of positions) { min.minimizeInPlace(point); max.maximizeInPlace(point) }
80
+ camera.setTarget(min.add(max).scale(.5), false, false, true)
81
+ const aspect = Math.max(.1, engine.getRenderWidth() / Math.max(1, engine.getRenderHeight()))
82
+ const fov = Math.min(camera.fov, 2 * Math.atan(Math.tan(camera.fov / 2) * aspect))
83
+ camera.upperRadiusLimit = 400
84
+ camera.radius = Math.max(camera.lowerRadiusLimit, max.subtract(min).length() / (2 * Math.sin(fov / 2)) + 2)
85
+ stopInertia()
86
+ }
87
+ function loop() {
88
+ frame = null
89
+ if (disposed || lost || document.hidden) return
90
+ navigator.frame()
91
+ frame = window.requestAnimationFrame(loop)
92
+ }
93
+ function active() { return !disposed && !lost && !document.hidden }
94
+ try {
95
+ engine = createEngine(canvas)
96
+ const baseFactory = createBabylonPortFactory(B, engine, { onSceneCreated(created) {
97
+ camera = created.camera
98
+ cameras.add(camera)
99
+ camera.lowerBetaLimit = .15
100
+ camera.upperBetaLimit = Math.PI / 2 - .05
101
+ camera.panningDistanceLimit = 150
102
+ camera.attachControl(canvas, true)
103
+ applyMotion()
104
+ } })
105
+ navigator = createGraphNavigator({ onIntent, createPort(profile, bind) {
106
+ const port = baseFactory(profile, bind)
107
+ const ownCamera = camera
108
+ // Preserve the retained controller and geometry; apply CR-4's measured
109
+ // orbit-preserving focus behavior at this presentation port boundary.
110
+ return { ...port, focus(position) {
111
+ ownCamera.setTarget(B.Vector3.FromArray(position), false, false, true)
112
+ for (const name of ['inertialAlphaOffset', 'inertialBetaOffset', 'inertialRadiusOffset', 'inertialPanningX', 'inertialPanningY']) ownCamera[name] = 0
113
+ }, dispose() {
114
+ ownCamera.detachControl()
115
+ cameras.delete(ownCamera)
116
+ if (camera === ownCamera) camera = null
117
+ port.dispose()
118
+ } }
119
+ } })
120
+ const mounted = navigator.mount(view)
121
+ engine.resize()
122
+ present(mounted)
123
+ listen(canvas, 'pointerdown', event => {
124
+ if (event.button === 0 && active()) down = { id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false }
125
+ })
126
+ listen(canvas, 'pointermove', event => {
127
+ if (down?.id === event.pointerId && Math.hypot(event.clientX - down.x, event.clientY - down.y) > 6) down.dragged = true
128
+ })
129
+ listen(canvas, 'pointerup', event => {
130
+ const start = down
131
+ down = null
132
+ if (!active() || !start || start.dragged || start.id !== event.pointerId || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 6) return
133
+ const rect = canvas.getBoundingClientRect()
134
+ if (!rect.width || !rect.height) return
135
+ const scale = engine.getHardwareScalingLevel()
136
+ const x = (event.clientX - rect.left) * engine.getRenderWidth() * scale / rect.width
137
+ const y = (event.clientY - rect.top) * engine.getRenderHeight() * scale / rect.height
138
+ navigator.pickAt(x, y)
139
+ })
140
+ for (const event of ['pointercancel', 'pointerleave']) listen(canvas, event, () => { down = null })
141
+ listen(window, 'resize', () => { if (active()) engine.resize() })
142
+ listen(document, 'visibilitychange', () => {
143
+ if (disposed || lost) return
144
+ if (document.hidden) { navigator.pause(); if (frame !== null) window.cancelAnimationFrame(frame); frame = null }
145
+ else { engine.resize(); navigator.resume(); if (frame === null) frame = window.requestAnimationFrame(loop) }
146
+ })
147
+ listen(canvas, 'webglcontextlost', event => {
148
+ event.preventDefault()
149
+ lost = true
150
+ navigator.pause()
151
+ if (frame !== null) window.cancelAnimationFrame(frame)
152
+ frame = null
153
+ available({ status: 'unavailable', reason: 'graphics-context-lost' })
154
+ })
155
+ if (motion) listen(motion, 'change', applyMotion)
156
+ if (document.hidden) navigator.pause()
157
+ else frame = window.requestAnimationFrame(loop)
158
+ available({ status: mounted.status, reducedMotion: Boolean(motion?.matches) })
159
+ return Object.freeze({
160
+ get status() { return disposed || lost ? 'unavailable' : navigator.snapshot().sceneNodes ? 'ready' : 'empty' },
161
+ select(ref, options) { return active() ? navigator.select(ref, options) : { status: 'unavailable' } },
162
+ selectEdge(edgeId, options) { return active() ? navigator.selectEdge(edgeId, options) : { status: 'unavailable' } },
163
+ receiveSelection(selection) { return active() ? navigator.receiveSelection(selection) : { status: 'unavailable' } },
164
+ focus(ref) { return active() ? navigator.focus(ref) : { status: 'unavailable' } },
165
+ expand(ref, direction) { return active() ? navigator.expand(ref, direction) : { status: 'unavailable' } },
166
+ openEvidence(evidenceId) { return active() ? navigator.openEvidence(evidenceId) : { status: 'unavailable' } },
167
+ setQuestion(questionId) { return active() ? navigator.setQuestion(questionId) : { status: 'unavailable' } },
168
+ setLens(lens) { if (!active()) return { status: 'unavailable' }; return present(navigator.setLens(lens)) },
169
+ update(next) { if (!active()) return { status: 'unavailable' }; return present(navigator.update(next)) },
170
+ fit() { if (active()) fit() },
171
+ reset() {
172
+ if (!active()) return
173
+ const result = navigator.setLens('all')
174
+ if (camera) { camera.alpha = Math.PI / 2; camera.beta = .7 }
175
+ present(result)
176
+ },
177
+ zoom(factor) { if (active() && camera && Number.isFinite(factor) && factor > 0) { camera.radius = Math.max(camera.lowerRadiusLimit, Math.min(camera.upperRadiusLimit, camera.radius * factor)); stopInertia() } },
178
+ pan(dx, dz) { if (active() && camera && Number.isFinite(dx) && Number.isFinite(dz)) { const target = camera.target; camera.setTarget(new B.Vector3(Math.max(-150, Math.min(150, target.x + dx)), target.y, Math.max(-150, Math.min(150, target.z + dz))), false, false, true); stopInertia() } },
179
+ snapshot() { return navigator?.snapshot() ?? null },
180
+ dispose,
181
+ clear: dispose,
182
+ })
183
+ } catch (error) {
184
+ dispose()
185
+ available({ status: 'unavailable', reason: 'graphics-unavailable' })
186
+ return unavailableHost('graphics-unavailable', dispose)
187
+ }
188
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runGraphViewCommand } from './command.mjs'
3
+ process.exitCode = await runGraphViewCommand(process.argv.slice(2))
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs/promises'
2
+ import { parseGraphView, refuse } from './contracts.mjs'
3
+
4
+ // A consuming CLI binds its authenticated reader through this port. The
5
+ // standalone validator reads fixture bytes only; a file is never an access grant.
6
+ export async function runGraphViewCommand(argv, { reader, stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
7
+ try {
8
+ const flags = new Map()
9
+ for (let index = 0; index < argv.length; index += 2) {
10
+ const key = argv[index], value = argv[index + 1]
11
+ if (!['--validate', '--request', '--out'].includes(key) || !value || value.startsWith('--') || flags.has(key)) refuse('invalid_arguments')
12
+ flags.set(key, value)
13
+ }
14
+ let bytes
15
+ if (flags.has('--validate')) {
16
+ if (flags.has('--request') || flags.has('--out') || reader) refuse('invalid_arguments')
17
+ parseGraphView(await fs.readFile(flags.get('--validate'), 'utf8'))
18
+ stdout('graph-view fixture valid\n')
19
+ return 0
20
+ } else {
21
+ if (!reader || typeof reader.exportGraphView !== 'function') refuse('host_binding_required')
22
+ const request = flags.has('--request') ? JSON.parse(await fs.readFile(flags.get('--request'), 'utf8')) : {}
23
+ bytes = await reader.exportGraphView(request)
24
+ }
25
+ if (flags.has('--out')) await fs.writeFile(flags.get('--out'), bytes, { flag: 'wx', mode: 0o600 })
26
+ else stdout(bytes)
27
+ return 0
28
+ } catch (error) {
29
+ const known = ['invalid_arguments', 'host_binding_required', 'policy_refusal', 'stale_source', 'stale_cursor', 'unavailable', 'budget_exceeded', 'contract_mismatch', 'digest_mismatch']
30
+ const code = known.includes(error?.code) ? error.code : 'operation_failed'
31
+ stderr(`graph-view: ${code}\n`)
32
+ return 2
33
+ }
34
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs'
2
+ import Ajv2020 from 'ajv/dist/2020.js'
3
+ import addFormats from 'ajv-formats'
4
+ import { canonicalize } from '../attestation/jcs.mjs'
5
+ import { createHash } from 'node:crypto'
6
+ import { refKey } from './refs.mjs'
7
+ import { refuse } from './errors.mjs'
8
+ export { refuse } from './errors.mjs'
9
+ export { refKey } from './refs.mjs'
10
+
11
+ export const GRAPH_VIEW_SCHEMA = 'atelier.graph-view@v1'
12
+ export const MAX_VIEW_BYTES = 2 * 1024 * 1024
13
+ const schema = JSON.parse(fs.readFileSync(new URL('../../contracts/atelier-graph-view.v1.schema.json', import.meta.url), 'utf8'))
14
+ const ajv = new Ajv2020({ allErrors: false, strict: true })
15
+ addFormats(ajv)
16
+ ajv.addSchema(schema)
17
+ const validators = new Map()
18
+ export const canonicalJson = canonicalize
19
+ export const digestJson = value => `sha256:${createHash('sha256').update(canonicalize(value)).digest('hex')}`
20
+ export const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
21
+ export const sortedUnique = values => [...new Set(values)].sort(compareText)
22
+
23
+ export function assertShape(name, value) {
24
+ const key = name ? `${schema.$id}#/$defs/${name}` : schema.$id
25
+ if (!validators.has(key)) validators.set(key, ajv.getSchema(key))
26
+ if (!validators.get(key)?.(value)) refuse('contract_mismatch')
27
+ return value
28
+ }
29
+
30
+ // Explicit schema properties are the only data boundary. No source body,
31
+ // relation map, host binding, error detail, or arbitrary ext object passes.
32
+ export function projectShape(name, value) {
33
+ const visit = (shape, input) => {
34
+ if (shape.$ref) return visit(schema.$defs[shape.$ref.split('/').at(-1)], input)
35
+ if (shape.type === 'object' && input && typeof input === 'object' && !Array.isArray(input)) {
36
+ const result = {}
37
+ for (const [key, child] of Object.entries(shape.properties ?? {})) {
38
+ if (Object.hasOwn(input, key)) result[key] = visit(child, input[key])
39
+ }
40
+ return result
41
+ }
42
+ if (shape.type === 'array' && Array.isArray(input)) return input.map(item => visit(shape.items, item))
43
+ return input
44
+ }
45
+ return assertShape(name, visit(schema.$defs[name], value))
46
+ }
47
+
48
+ export function assertRelativePath(value) {
49
+ if (typeof value !== 'string' || !value || value.includes('\\') || value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..') || /[\u0000-\u001f\u007f]/.test(value)) refuse('contract_mismatch')
50
+ }
51
+
52
+ export function serializeGraphView(view) {
53
+ assertShape(null, view)
54
+ const nodeIds = new Set(view.nodes.map(node => refKey(node.ref)))
55
+ const edgeIds = new Set(view.edges.map(edge => edge.edgeId))
56
+ const evidenceIds = new Set(view.evidence.map(item => item.evidenceId))
57
+ if (nodeIds.size !== view.nodes.length || edgeIds.size !== view.edges.length || evidenceIds.size !== view.evidence.length) refuse('contract_mismatch')
58
+ for (const edge of view.edges) {
59
+ if (!nodeIds.has(refKey(edge.source)) || !nodeIds.has(refKey(edge.target)) || edge.supportRefs.length === 0 || !edge.supportRefs.includes(edge.sourceRef) || edge.supportRefs.some(id => !evidenceIds.has(id))) refuse('contract_mismatch')
60
+ }
61
+ for (const item of [...view.questions, ...view.diagnostics]) {
62
+ if (item.nodeRefs.some(ref => !nodeIds.has(refKey(ref))) || [...item.evidenceRefs, ...(item.contradictingRefs ?? [])].some(id => !evidenceIds.has(id))) refuse('contract_mismatch')
63
+ }
64
+ for (const table of ['nodes', 'edges', 'evidence', 'questions', 'diagnostics']) if (view.page.returned[table] !== view[table].length) refuse('contract_mismatch')
65
+ if (view.page.truncated !== Object.values(view.page.omitted).some(value => value > 0)) refuse('contract_mismatch')
66
+ const { projectionDigest, ...payload } = view
67
+ if (digestJson(payload) !== projectionDigest) refuse('digest_mismatch')
68
+ const bytes = `${canonicalize(view)}\n`
69
+ if (Buffer.byteLength(bytes) > MAX_VIEW_BYTES) refuse('budget_exceeded')
70
+ return bytes
71
+ }
72
+
73
+ export function parseGraphView(bytes) {
74
+ if (typeof bytes !== 'string' || Buffer.byteLength(bytes) > MAX_VIEW_BYTES) refuse('budget_exceeded')
75
+ let view
76
+ try { view = JSON.parse(bytes) } catch { refuse('contract_mismatch') }
77
+ serializeGraphView(view)
78
+ return view
79
+ }
@@ -0,0 +1,5 @@
1
+ export function refuse(code) {
2
+ const error = new Error(`graph-view: ${code}`)
3
+ error.code = code
4
+ throw error
5
+ }
@@ -0,0 +1,4 @@
1
+ export { GRAPH_VIEW_SCHEMA, MAX_VIEW_BYTES, parseGraphView, serializeGraphView } from './contracts.mjs'
2
+ export { createGraphViewReader } from './query.mjs'
3
+ export { runGraphViewCommand } from './command.mjs'
4
+ export { assertGraphViewIntent } from './intents.mjs'
@@ -0,0 +1,30 @@
1
+ import { deepFreeze } from '../composition/wire.mjs'
2
+ import { refKey } from './refs.mjs'
3
+ import { refuse } from './errors.mjs'
4
+
5
+ // Validation emits no action. A host must separately revalidate current grants
6
+ // and source before accepting any stale-sensitive operation.
7
+ export function assertGraphViewIntent(view, intent) {
8
+ if (!intent || intent.projectionDigest !== view.projectionDigest) refuse('stale_source')
9
+ const specifications = {
10
+ select: ['type', 'projectionDigest', ...(Object.hasOwn(intent, 'ref') ? ['ref'] : ['edgeId'])],
11
+ expand: ['type', 'projectionDigest', 'ref', 'direction'],
12
+ openEvidence: ['type', 'projectionDigest', 'evidenceId'],
13
+ setQuestion: ['type', 'projectionDigest', 'questionId'],
14
+ compareSnapshots: ['type', 'projectionDigest', 'a', 'b'],
15
+ proposeModelChange: ['type', 'projectionDigest', 'draftRef'],
16
+ }
17
+ const allowed = Object.hasOwn(specifications, intent.type) ? specifications[intent.type] : null
18
+ if (!allowed || Object.keys(intent).sort().join(',') !== allowed.sort().join(',')) refuse('contract_mismatch')
19
+ if (intent.ref) {
20
+ if (Object.keys(intent.ref).sort().join(',') !== 'localId,repoId' || !view.nodes.some(node => refKey(node.ref) === refKey(intent.ref))) refuse('unavailable')
21
+ }
22
+ if (intent.edgeId && !view.edges.some(edge => edge.edgeId === intent.edgeId)) refuse('unavailable')
23
+ if (intent.type === 'select' && !intent.ref && !intent.edgeId) refuse('contract_mismatch')
24
+ if (intent.type === 'expand' && (!intent.ref || !['in', 'out', 'both'].includes(intent.direction) || !view.capabilities.queries.includes('neighborhood'))) refuse('contract_mismatch')
25
+ if (intent.type === 'openEvidence' && !view.evidence.some(item => item.evidenceId === intent.evidenceId)) refuse('unavailable')
26
+ if (intent.type === 'setQuestion' && !view.questions.some(item => item.questionId === intent.questionId)) refuse('unavailable')
27
+ if (intent.type === 'compareSnapshots' && ![intent.a, intent.b].every(value => typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value))) refuse('contract_mismatch')
28
+ if (intent.type === 'proposeModelChange' && (!view.capabilities.proposalIntents.includes('proposeModelChange') || typeof intent.draftRef !== 'string' || !intent.draftRef || intent.draftRef.length > 512)) refuse('policy_refusal')
29
+ return deepFreeze(JSON.parse(JSON.stringify(intent)))
30
+ }
@@ -0,0 +1,213 @@
1
+ import { VALID_RELATIONS } from '../graph/graph.mjs'
2
+ import { GRAPH_VIEW_SCHEMA, assertShape, assertRelativePath, projectShape, digestJson, canonicalJson, refKey, compareText, sortedUnique, refuse, serializeGraphView } from './contracts.mjs'
3
+
4
+ const TABLES = ['nodes', 'edges', 'evidence', 'questions', 'diagnostics']
5
+ const keys = { nodes: item => refKey(item.ref), edges: item => item.edgeId, evidence: item => item.evidenceId, questions: item => item.questionId, diagnostics: item => item.diagnosticId }
6
+ const shapes = { nodes: 'node', edges: 'edge', evidence: 'evidence', questions: 'question', diagnostics: 'diagnostic' }
7
+ const sourceKey = pin => canonicalJson([pin.repoId, pin.path, pin.revision, pin.digest])
8
+ const sortRefs = refs => refs.sort((a, b) => compareText(refKey(a), refKey(b)))
9
+ const count = tables => Object.fromEntries(TABLES.map(key => [key, tables[key].length]))
10
+
11
+ function indexUnique(items, key) {
12
+ const result = new Map()
13
+ for (const item of items) {
14
+ const id = key(item)
15
+ if (result.has(id)) refuse('contract_mismatch')
16
+ result.set(id, item)
17
+ }
18
+ return result
19
+ }
20
+
21
+ function normalize(raw) {
22
+ if (!raw || typeof raw.producerVersion !== 'string' || !raw.producerVersion || raw.producerVersion.length > 128) refuse('contract_mismatch')
23
+ if (!['complete', 'incomplete'].includes(raw.completeness)) refuse('contract_mismatch')
24
+ const graph = { producerVersion: raw.producerVersion, completeness: raw.completeness }
25
+ graph.sourceSnapshot = projectShape('sourceSnapshot', {
26
+ ...raw.sourceSnapshot,
27
+ repositories: raw.sourceSnapshot?.repositories?.map(repo => ({ ...repo, omittedFiles: 0 })),
28
+ })
29
+ const expiry = raw.accessContext?.expiresAt
30
+ graph.accessContext = projectShape('accessContext', { ...raw.accessContext, expiresAt: typeof expiry === 'number' && Number.isFinite(expiry) ? new Date(expiry).toISOString() : expiry })
31
+ graph.capabilities = projectShape('capabilities', { maxSlice: 500, maxDepth: 2, sourceReads: false, proposalIntents: [], queries: ['metadata-search', 'neighborhood', 'question', 'source-impact'], ...raw.capabilities })
32
+ graph.accessContext.authorizedRepoIds = sortedUnique(graph.accessContext.authorizedRepoIds)
33
+ graph.capabilities.queries = sortedUnique(graph.capabilities.queries)
34
+ graph.capabilities.proposalIntents = sortedUnique(graph.capabilities.proposalIntents)
35
+ const repositories = indexUnique(graph.sourceSnapshot.repositories, repo => repo.repoId)
36
+ if ([...repositories.keys()].some(id => !graph.accessContext.authorizedRepoIds.includes(id))) refuse('contract_mismatch')
37
+ for (const repo of repositories.values()) {
38
+ indexUnique(repo.files, file => file.path)
39
+ for (const file of repo.files) assertRelativePath(file.path)
40
+ repo.files.sort((a, b) => compareText(a.path, b.path))
41
+ }
42
+ graph.sourceSnapshot.repositories.sort((a, b) => compareText(a.repoId, b.repoId))
43
+ for (const table of TABLES) {
44
+ if (!Array.isArray(raw[table]) || raw[table].length > 20000) refuse('budget_exceeded')
45
+ graph[table] = raw[table].map(item => projectShape(shapes[table], table === 'diagnostics' && item.code === 'authorized-source-unavailable' ? { ...item, diagnosticId: digestJson({ code: item.code, repoId: item.repoId }) } : item))
46
+ indexUnique(graph[table], keys[table])
47
+ graph[table].sort((a, b) => compareText(keys[table](a), keys[table](b)))
48
+ }
49
+ const missingRepositories = graph.accessContext.authorizedRepoIds.filter(id => !repositories.has(id))
50
+ const unavailable = graph.diagnostics.filter(item => item.code === 'authorized-source-unavailable').map(item => item.repoId).sort(compareText)
51
+ if (canonicalJson(missingRepositories) !== canonicalJson(unavailable) || (missingRepositories.length > 0 && graph.completeness !== 'incomplete') || graph.diagnostics.some(item => item.repoId && !graph.accessContext.authorizedRepoIds.includes(item.repoId))) refuse('contract_mismatch')
52
+ for (const node of graph.nodes) node.tags = sortedUnique(node.tags)
53
+ if (raw.edges.some(edge => edge.eligible === false)) refuse('contract_mismatch')
54
+ for (const edge of graph.edges) {
55
+ if (edge.predicate !== 'links_to' && !VALID_RELATIONS.has(edge.predicate)) refuse('contract_mismatch')
56
+ edge.supportRefs = sortedUnique(edge.supportRefs)
57
+ edge.reviewRefs = sortedUnique(edge.reviewRefs)
58
+ }
59
+ for (const item of [...graph.questions, ...graph.diagnostics]) {
60
+ sortRefs(item.nodeRefs)
61
+ item.evidenceRefs = sortedUnique(item.evidenceRefs)
62
+ if (item.contradictingRefs) item.contradictingRefs = sortedUnique(item.contradictingRefs)
63
+ if (item.gaps) item.gaps = sortedUnique(item.gaps)
64
+ }
65
+ const nodes = new Map(graph.nodes.map(node => [refKey(node.ref), node]))
66
+ const evidence = new Map(graph.evidence.map(item => [item.evidenceId, item]))
67
+ const checkSource = (source, verified = true) => {
68
+ assertRelativePath(source.path)
69
+ if (source.locator?.rangeDigest && !source.locator.range) refuse('contract_mismatch')
70
+ for (const range of [source.locator?.range, source.locator?.targetRange].filter(Boolean)) {
71
+ if (range.end < range.start || (range.byteStart === undefined) !== (range.byteEnd === undefined) || (range.byteStart !== undefined && range.byteEnd < range.byteStart)) refuse('contract_mismatch')
72
+ }
73
+ const repo = repositories.get(source.repoId)
74
+ if (!repo) refuse('contract_mismatch')
75
+ if (verified && (source.revision !== repo.digest || !repo.files.some(file => file.path === source.path && file.rawDigest === source.digest))) refuse('contract_mismatch')
76
+ }
77
+ for (const node of graph.nodes) {
78
+ if (node.ref.repoId !== node.sourceRef.repoId) refuse('contract_mismatch')
79
+ checkSource(node.sourceRef)
80
+ }
81
+ for (const item of graph.evidence) checkSource(item.sourceRef, item.verification === 'verified-source')
82
+ for (const repo of repositories.values()) for (const field of ['config', 'plan']) if (repo.inputs[field]) {
83
+ if (repo.inputs[field].repoId !== repo.repoId) refuse('contract_mismatch')
84
+ checkSource(repo.inputs[field])
85
+ }
86
+ for (const edge of graph.edges) {
87
+ if (!nodes.has(refKey(edge.source)) || !nodes.has(refKey(edge.target)) || !evidence.has(edge.sourceRef) || !edge.supportRefs.includes(edge.sourceRef) || edge.supportRefs.length === 0 || edge.supportRefs.some(id => !evidence.has(id))) refuse('contract_mismatch')
88
+ }
89
+ for (const item of [...graph.questions, ...graph.diagnostics]) {
90
+ if (item.nodeRefs.some(ref => !nodes.has(refKey(ref))) || [...item.evidenceRefs, ...(item.contradictingRefs ?? [])].some(id => !evidence.has(id))) refuse('contract_mismatch')
91
+ }
92
+ return graph
93
+ }
94
+
95
+ function normalizedRequest(request, graph) {
96
+ assertShape('request', request)
97
+ if (request.limit && request.limit > graph.capabilities.maxSlice) refuse('budget_exceeded')
98
+ if ((request.depth ?? 0) > graph.capabilities.maxDepth) refuse('budget_exceeded')
99
+ if (request.search && !graph.capabilities.queries.includes('metadata-search') || request.questionId && !graph.capabilities.queries.includes('question') || request.focusRef && !graph.capabilities.queries.includes('neighborhood')) refuse('policy_refusal')
100
+ const query = {
101
+ questionId: request.questionId ?? null,
102
+ focusRef: request.focusRef ?? null,
103
+ relationFilter: sortedUnique(request.relationFilter ?? []),
104
+ direction: request.direction ?? 'both',
105
+ depth: request.depth ?? 0,
106
+ limit: request.limit ?? Math.min(100, graph.capabilities.maxSlice),
107
+ search: (request.search ?? '').normalize('NFC').trim().toLowerCase(),
108
+ }
109
+ if (query.depth > 0 && !query.focusRef) refuse('contract_mismatch')
110
+ return query
111
+ }
112
+
113
+ function slice(graph, query, request, snapshot) {
114
+ let candidates = graph.nodes
115
+ let question = null
116
+ if (query.questionId) {
117
+ question = graph.questions.find(item => item.questionId === query.questionId)
118
+ if (!question) refuse('unavailable')
119
+ const allowed = new Set(question.nodeRefs.map(refKey))
120
+ candidates = candidates.filter(node => allowed.has(refKey(node.ref)))
121
+ }
122
+ if (query.focusRef) {
123
+ const focus = refKey(query.focusRef)
124
+ if (!graph.nodes.some(node => refKey(node.ref) === focus)) refuse('unavailable')
125
+ const visited = new Set([focus])
126
+ let frontier = new Set([focus])
127
+ for (let depth = 0; depth < query.depth; depth += 1) {
128
+ const next = new Set()
129
+ for (const edge of graph.edges) {
130
+ if (query.relationFilter.length && !query.relationFilter.includes(edge.predicate)) continue
131
+ const from = refKey(edge.source), to = refKey(edge.target)
132
+ if (query.direction !== 'in' && frontier.has(from)) next.add(to)
133
+ if (query.direction !== 'out' && frontier.has(to)) next.add(from)
134
+ }
135
+ frontier = new Set([...next].filter(key => !visited.has(key)))
136
+ for (const key of frontier) visited.add(key)
137
+ }
138
+ candidates = candidates.filter(node => visited.has(refKey(node.ref)))
139
+ }
140
+ if (query.search) candidates = candidates.filter(node => [node.title, node.summary, ...node.tags].some(value => value.toLowerCase().includes(query.search)))
141
+ const queryDigest = digestJson({ snapshot, query })
142
+ let offset = 0
143
+ if (request.cursor) {
144
+ let cursor
145
+ try { cursor = JSON.parse(Buffer.from(request.cursor, 'base64url').toString('utf8')) } catch { refuse('stale_cursor') }
146
+ if (!cursor || Object.keys(cursor).sort().join(',') !== 'offset,queryDigest' || cursor.queryDigest !== queryDigest || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || cursor.offset >= candidates.length || cursor.offset % query.limit !== 0) refuse('stale_cursor')
147
+ offset = cursor.offset
148
+ }
149
+ const nodes = candidates.slice(offset, offset + query.limit)
150
+ const candidateRefs = new Set(candidates.map(node => refKey(node.ref)))
151
+ const visibleRefs = new Set(nodes.map(node => refKey(node.ref)))
152
+ const matchingEdges = graph.edges.filter(edge => candidateRefs.has(refKey(edge.source)) && candidateRefs.has(refKey(edge.target)) && (!query.relationFilter.length || query.relationFilter.includes(edge.predicate)))
153
+ const edges = matchingEdges.filter(edge => visibleRefs.has(refKey(edge.source)) && visibleRefs.has(refKey(edge.target))).slice(0, Math.min(2000, query.limit * 4))
154
+ const needed = new Set(edges.flatMap(edge => [edge.sourceRef, ...edge.supportRefs]))
155
+ const nodeSources = new Set(nodes.map(node => sourceKey(node.sourceRef)))
156
+ for (const item of graph.evidence) if (nodeSources.has(sourceKey(item.sourceRef))) needed.add(item.evidenceId)
157
+ const selectedQuestions = (question ? [question] : graph.questions.filter(item => (!query.focusRef && !query.search) || item.nodeRefs.some(ref => visibleRefs.has(refKey(ref))))).slice(0, 100)
158
+ for (const item of selectedQuestions) for (const id of [...item.evidenceRefs, ...item.contradictingRefs]) needed.add(id)
159
+ if (needed.size > 4000) refuse('budget_exceeded')
160
+ const evidence = graph.evidence.filter(item => needed.has(item.evidenceId))
161
+ const evidenceIds = new Set(evidence.map(item => item.evidenceId))
162
+ const questions = selectedQuestions.map(item => ({ ...item, nodeRefs: item.nodeRefs.filter(ref => visibleRefs.has(refKey(ref))), omissionState: item.nodeRefs.some(ref => !visibleRefs.has(refKey(ref))) ? 'bounded' : item.omissionState }))
163
+ const diagnostics = graph.diagnostics.filter(item => item.nodeRefs.every(ref => visibleRefs.has(refKey(ref))) && item.evidenceRefs.every(id => evidenceIds.has(id))).slice(0, query.limit)
164
+ const selected = { nodes, edges, evidence, questions, diagnostics }
165
+ const total = { nodes: candidates, edges: matchingEdges, evidence: graph.evidence, questions: question ? [question] : graph.questions, diagnostics: graph.diagnostics }
166
+ const returned = count(selected), totals = count(total)
167
+ const omitted = Object.fromEntries(TABLES.map(key => [key, totals[key] - returned[key]]))
168
+ const nextCursor = offset + nodes.length < candidates.length ? Buffer.from(canonicalJson({ offset: offset + nodes.length, queryDigest })).toString('base64url') : null
169
+ const sourcePins = [...nodes.map(node => node.sourceRef), ...evidence.map(item => item.sourceRef), ...graph.sourceSnapshot.repositories.flatMap(repo => [repo.inputs.config, repo.inputs.plan]).filter(Boolean)]
170
+ const sourceSnapshot = { ...graph.sourceSnapshot, repositories: graph.sourceSnapshot.repositories.map(repo => {
171
+ const selectedPaths = new Set(sourcePins.filter(pin => pin.repoId === repo.repoId).map(pin => pin.path))
172
+ const files = repo.files.filter(file => selectedPaths.has(file.path))
173
+ return { ...repo, files, omittedFiles: repo.files.length - files.length }
174
+ }) }
175
+ const payload = { schema: GRAPH_VIEW_SCHEMA, producerVersion: graph.producerVersion, snapshot, completeness: graph.completeness, sourceSnapshot, accessContext: graph.accessContext, ...selected, capabilities: graph.capabilities, query, page: { offset, limit: query.limit, returned, omitted, truncated: Object.values(omitted).some(value => value > 0), nextCursor } }
176
+ return { ...payload, projectionDigest: digestJson(payload) }
177
+ }
178
+
179
+ // Host-only ports close over authenticated context. No caller-supplied graph,
180
+ // identity, scope, or grant enters the request. Nothing is cached here.
181
+ export function createGraphViewReader({ loadAuthorizedGraph, revalidate, now = () => Date.now() }) {
182
+ if (typeof loadAuthorizedGraph !== 'function' || typeof revalidate !== 'function' || typeof now !== 'function') throw new TypeError('graph-view host ports required')
183
+ const run = async (request, operation) => {
184
+ assertShape('request', request)
185
+ let loaded
186
+ try { loaded = await loadAuthorizedGraph({ operation }) } catch { refuse('policy_refusal') }
187
+ if (!loaded?.binding || !loaded.graph) refuse('policy_refusal')
188
+ let view, bytes, pendingError
189
+ const expiresAt = typeof loaded.graph?.accessContext?.expiresAt === 'number' ? loaded.graph.accessContext.expiresAt : Date.parse(loaded.graph?.accessContext?.expiresAt)
190
+ try {
191
+ const graph = normalize(loaded.graph)
192
+ const snapshot = digestJson(graph)
193
+ if (request.snapshot && request.snapshot !== snapshot) refuse('stale_source')
194
+ const query = normalizedRequest(request, graph)
195
+ if (operation === 'export' && graph.capabilities.export !== true) refuse('policy_refusal')
196
+ view = slice(graph, query, request, snapshot)
197
+ bytes = serializeGraphView(view)
198
+ } catch (error) { pendingError = error }
199
+ let current = false
200
+ try { current = await revalidate(loaded.binding) } catch { refuse('policy_refusal') }
201
+ const deliveredAt = now()
202
+ if (current !== true || !Number.isFinite(expiresAt) || !Number.isFinite(deliveredAt) || deliveredAt >= expiresAt) refuse('policy_refusal')
203
+ if (pendingError) {
204
+ if (!['contract_mismatch', 'budget_exceeded', 'policy_refusal', 'stale_source', 'stale_cursor', 'unavailable'].includes(pendingError.code)) refuse('contract_mismatch')
205
+ throw pendingError
206
+ }
207
+ return { view, bytes }
208
+ }
209
+ return Object.freeze({
210
+ readGraphView: async (request = {}) => (await run(request, 'read')).view,
211
+ exportGraphView: async (request = {}) => (await run(request, 'export')).bytes,
212
+ })
213
+ }
@@ -0,0 +1,2 @@
1
+ // The qualified identity is supplied by the source owner; never title-derived.
2
+ export const refKey = ref => JSON.stringify([ref.repoId, ref.localId])