@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,405 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+ import { canonicalize } from '../attestation/jcs.mjs'
5
+ import { identityKey } from '../project/repo-identity.mjs'
6
+ import { openRegularFileNoFollow } from '../project/private-state.mjs'
7
+ import { resolveGitExecutable, runGit } from '../runtime/git-adapter.mjs'
8
+ import { VALID_AUDIENCES } from '../projection/policy.mjs'
9
+ import {
10
+ censusScopeFilter, markdownMetadata, nodeForFile, resolveWorkspaceLinks,
11
+ scanMarkdownSource, validateKnowledgeGraph, walkDocuments, walkSidecars, VALID_KG_TYPES, VALID_STATUSES, VALID_RELATION_TYPES,
12
+ } from './knowledge-graph.mjs'
13
+
14
+ export const REPO_CONTRIBUTION_SCHEMA = 'atelier.repo-contribution@v1'
15
+ const PROFILE = 'markdown-explicit-id/v1'
16
+ const digestPattern = /^sha256:[0-9a-f]{64}$/
17
+ const objectIdPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
18
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
19
+ const plain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v))
20
+ const text = (v) => typeof v === 'string' && v.trim().length > 0 && !/[\u0000-\u001f]/.test(v)
21
+ const relative = (v) => text(v) && !v.includes('\\') && !/^(?:\/|~|[A-Za-z]:)/.test(v) && v.split('/').every((s) => s !== '' && s !== '.' && s !== '..')
22
+ const sourcePath = (v) => relative(v) && !v.split('/').some((part) => part.toLowerCase() === '.git')
23
+ const hash = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
24
+ const bodyDigest = (value) => hash(canonicalize(Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'digest'))))
25
+ const pointerToken = (value) => value.replaceAll('~', '~0').replaceAll('/', '~1')
26
+
27
+ // Locate the same small YAML subset the source parser accepts, in the raw
28
+ // source. These offsets never come from normalized or reserialized metadata.
29
+ function relationSourceLocations(raw) {
30
+ const found = new Map()
31
+ const stack = [{ indent: -1, parts: [], index: 0 }]
32
+ const span = (start, end) => ({ start, end, byteStart: Buffer.byteLength(raw.slice(0, start)), byteEnd: Buffer.byteLength(raw.slice(0, end)) })
33
+ const put = (parts, start, end) => {
34
+ if (parts[0] === 'kg' && parts[1] === 'relations' && parts.length >= 3) found.set(`/${parts.map(pointerToken).join('/')}`, span(start, end))
35
+ }
36
+ let first = true
37
+ for (const match of raw.matchAll(/[^\r\n]+|\r\n|\r|\n/g)) {
38
+ const line = match[0]
39
+ if (/^[\r\n]/.test(line)) continue
40
+ if (first) { first = false; continue }
41
+ const trimmed = line.trim()
42
+ if (trimmed === '---') break
43
+ if (!trimmed || trimmed.startsWith('#')) continue
44
+ const indent = line.replaceAll('\t', ' ').match(/^\s*/)[0].length
45
+ while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop()
46
+ const parent = stack.at(-1)
47
+ const position = match.index + line.indexOf(trimmed)
48
+ if (trimmed.startsWith('- ')) {
49
+ const spelling = trimmed.slice(2).trim()
50
+ if (spelling) put([...parent.parts, String(parent.index++)], position + trimmed.indexOf(spelling, 2), position + trimmed.length)
51
+ continue
52
+ }
53
+ const keyValue = /^([^:]+):(.*)$/.exec(trimmed)
54
+ if (!keyValue) continue
55
+ const parts = [...parent.parts, keyValue[1].trim()]
56
+ const pointer = `/${parts.map(pointerToken).join('/')}`
57
+ // Repeated assignments replace their previous value in parseYamlSubset.
58
+ for (const previous of found.keys()) if (previous === pointer || previous.startsWith(`${pointer}/`)) found.delete(previous)
59
+ const value = keyValue[2].trim()
60
+ if (!value) { stack.push({ indent, parts, index: 0 }); continue }
61
+ const valueStart = position + trimmed.indexOf(':') + 1 + keyValue[2].indexOf(value)
62
+ if (value.startsWith('[') && value.endsWith(']')) {
63
+ let offset = valueStart + 1
64
+ let index = 0
65
+ for (const part of value.slice(1, -1).split(',')) {
66
+ const spelling = part.trim()
67
+ if (spelling && spelling !== '""' && spelling !== "''") put([...parts, String(index++)], offset + part.indexOf(spelling), offset + part.indexOf(spelling) + spelling.length)
68
+ offset += part.length + 1
69
+ }
70
+ } else put(parts, valueStart, valueStart + value.length)
71
+ }
72
+ return found
73
+ }
74
+
75
+ export class RepositoryContributionError extends Error {
76
+ constructor(code) {
77
+ super(code)
78
+ this.name = 'RepositoryContributionError'
79
+ this.code = code
80
+ }
81
+ }
82
+ const refuse = (code) => { throw new RepositoryContributionError(code) }
83
+
84
+ export function nodeRefKey(ref) {
85
+ if (!plain(ref) || !text(ref.repoId) || !text(ref.localId)) refuse('invalid-node-ref')
86
+ return JSON.stringify([ref.repoId, ref.localId])
87
+ }
88
+
89
+ function normalizeScope(scope = {}) {
90
+ if (!plain(scope) || Object.keys(scope).some((key) => !['base', 'include', 'exclude'].includes(key))) refuse('invalid-census-scope')
91
+ const result = { base: scope.base ?? '', include: scope.include ?? null, exclude: scope.exclude ?? null }
92
+ if (typeof result.base !== 'string' || result.base.includes('\\') || /[\u0000-\u001f]/.test(result.base) || /^(?:\/|~|[A-Za-z]:)/.test(result.base)) refuse('invalid-census-scope')
93
+ for (const key of ['include', 'exclude']) if (result[key] !== null && (!text(result[key]) || result[key].includes('\\') || /^(?:\/|~|[A-Za-z]:)/.test(result[key]))) refuse('invalid-census-scope')
94
+ return result
95
+ }
96
+
97
+ function readBytes(root, rel) {
98
+ if (!sourcePath(rel)) refuse('invalid-source-path')
99
+ let cursor = root
100
+ const segments = rel.split('/')
101
+ // The repository's own metadata identity is retained for every component, so
102
+ // a hard link to a worktree metadata file refuses even below a subdirectory.
103
+ // An observed repository always has it; its absence means the repository
104
+ // changed after observation, and nothing is read without the guard.
105
+ let rootMetadata
106
+ try { rootMetadata = fs.statSync(path.join(root, '.git'), { bigint: true }) } catch (error) {
107
+ if (error?.code !== 'ENOENT') throw error
108
+ refuse('repository-snapshot-changed')
109
+ }
110
+ for (const [index, segment] of segments.entries()) {
111
+ const next = path.join(cursor, segment)
112
+ const stat = fs.lstatSync(next, { bigint: true })
113
+ // On Windows a short-name spelling can name the same metadata object
114
+ // without containing a literal .git segment. Bind every component,
115
+ // including a worktree's metadata file, before opening source bytes.
116
+ let metadata
117
+ try { metadata = fs.statSync(path.join(cursor, '.git'), { bigint: true }) } catch (error) {
118
+ if (error?.code !== 'ENOENT') throw error
119
+ }
120
+ if (metadata && stat.dev === metadata.dev && stat.ino === metadata.ino) refuse('invalid-source-path')
121
+ if (stat.dev === rootMetadata.dev && stat.ino === rootMetadata.ino) refuse('invalid-source-path')
122
+ cursor = next
123
+ if (index < segments.length - 1) {
124
+ if (!stat.isDirectory() || stat.isSymbolicLink()) refuse('redirected-source-path')
125
+ rejectNestedRepository(cursor)
126
+ }
127
+ }
128
+ const fd = openRegularFileNoFollow(cursor)
129
+ try { return fs.readFileSync(fd) } finally { fs.closeSync(fd) }
130
+ }
131
+
132
+ function rejectNestedRepository(directory) {
133
+ try { fs.lstatSync(path.join(directory, '.git')) } catch (error) {
134
+ if (error?.code === 'ENOENT') return
135
+ throw error
136
+ }
137
+ refuse('nested-repository-source')
138
+ }
139
+
140
+ function gitReader(root) {
141
+ const executable = resolveGitExecutable()
142
+ return (args) => {
143
+ const result = runGit(executable, root, args, { allowFailure: true })
144
+ if (!result.ok) refuse('repository-observation-unavailable')
145
+ return result.stdout
146
+ }
147
+ }
148
+
149
+ function observe(git) {
150
+ const head = git(['rev-parse', '--verify', 'HEAD']).trim()
151
+ const tree = git(['rev-parse', '--verify', 'HEAD^{tree}']).trim()
152
+ if (!objectIdPattern.test(head) || !objectIdPattern.test(tree)) refuse('repository-revision-unavailable')
153
+ const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all'])
154
+ if (status !== '' && !status.endsWith('\0')) refuse('repository-observation-incomplete')
155
+ return { head, tree, status, dirty: status.length > 0 }
156
+ }
157
+
158
+ function census(root, git, scope) {
159
+ const output = git(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'])
160
+ if (output !== '' && !output.endsWith('\0')) refuse('repository-observation-incomplete')
161
+ const ignored = new Set(output.split('\0').filter(Boolean).map((item) => item.replace(/\/+$/, '')))
162
+ const outside = censusScopeFilter(scope)
163
+ const isIgnored = (rel, info) => {
164
+ if (ignored.has(rel.replace(/\/+$/, '')) || (outside?.(rel, info) ?? false)) return true
165
+ // A nested clone or submodule owns its own source. The caller must select
166
+ // it separately or explicitly exclude it from this repository's scope.
167
+ if (info?.directory) rejectNestedRepository(path.join(root, rel))
168
+ return false
169
+ }
170
+ const files = walkDocuments(root, root, [], isIgnored).sort((a, b) => compare(a.rel, b.rel))
171
+ // Other formats require the existing metadata reader's pinned-byte seam.
172
+ // Never silently omit them, or turn an incomplete contribution into a complete one.
173
+ if (files.some((file) => file.ext !== '.md') || walkSidecars(root, root, [], isIgnored).length) refuse('source-profile-unsupported')
174
+ return files
175
+ }
176
+
177
+ /**
178
+ * A derived local contribution. No network lookup, authorization, assertion
179
+ * acceptance or writes occur here. Provider IDs must already be recorded by
180
+ * the repository's existing adoption path. Unsupported identity/format refuses.
181
+ * @param {{name:string, path:string, identity:{provider:'github', id:string}, aliases?:string[]}} repo
182
+ * @param {import('./repo-contribution.types.mjs').SourceBuilderOptions} [options]
183
+ * @returns {import('./repo-contribution.types.mjs').RepositoryContribution}
184
+ */
185
+ export function buildRepositoryContribution(repo, { scope: scopeInput, inputPaths = {} } = {}) {
186
+ try {
187
+ if (!plain(repo) || !text(repo.name) || !text(repo.path)) refuse('invalid-repository')
188
+ if (!plain(repo.identity) || repo.identity.provider !== 'github' || !text(repo.identity.id)) refuse('stable-repository-identity-required')
189
+ if (!plain(inputPaths) || Object.keys(inputPaths).some((key) => !['config', 'plan'].includes(key))) refuse('invalid-input-paths')
190
+ const repoId = identityKey(repo.identity)
191
+ const scope = normalizeScope(scopeInput)
192
+ const root = fs.realpathSync(repo.path)
193
+ const git = gitReader(root)
194
+ // Filesystem identity handles Windows case/short-name aliases without
195
+ // treating a different directory as the enrolled repository root.
196
+ const requestedRoot = fs.statSync(root, { bigint: true })
197
+ const gitRoot = fs.statSync(git(['rev-parse', '--show-toplevel']).trim(), { bigint: true })
198
+ if (!requestedRoot.isDirectory() || !gitRoot.isDirectory() || requestedRoot.ino === 0n || gitRoot.ino === 0n || requestedRoot.dev !== gitRoot.dev || requestedRoot.ino !== gitRoot.ino) refuse('repository-root-mismatch')
199
+ const before = observe(git)
200
+ const files = census(root, git, scope)
201
+ const pins = new Map()
202
+ const contents = new Map()
203
+ const capture = (rel) => {
204
+ if (!contents.has(rel)) {
205
+ const bytes = readBytes(root, rel)
206
+ contents.set(rel, bytes)
207
+ pins.set(rel, { path: rel, rawDigest: hash(bytes), byteLength: bytes.length })
208
+ }
209
+ return contents.get(rel)
210
+ }
211
+ const nodes = []
212
+ const scanned = new Map()
213
+ const metadata = new Map()
214
+ const locations = new Map()
215
+ const coverage = { surfaced: new Set(), sections: new Map() }
216
+ for (const file of files) {
217
+ const bytes = capture(file.rel)
218
+ const raw = bytes.toString('utf8')
219
+ const scan = scanMarkdownSource(bytes)
220
+ if (!scan.utf8Exact) refuse('source-not-utf8')
221
+ const node = nodeForFile(repo.name, root, coverage, file, null, raw)
222
+ if (node.classification !== 'classified' || node.markdownHasKgId !== true) refuse('explicit-source-identity-required')
223
+ nodes.push(node)
224
+ scanned.set(node, scan)
225
+ metadata.set(node, markdownMetadata(raw))
226
+ locations.set(node, relationSourceLocations(raw))
227
+ }
228
+ // Endpoint availability is a composition concern. Source validation still
229
+ // checks IDs, classifications, predicate vocabulary and relation shapes.
230
+ if (validateKnowledgeGraph(nodes, []).length) refuse('source-graph-invalid')
231
+ for (const input of Object.values(inputPaths)) capture(input)
232
+ // This profile resolves document links only. Keep original embed flags in
233
+ // the contribution, while disabling the resolver's asset-directory scan.
234
+ const linkScans = new Map([...scanned].map(([node, scan]) => [node, { ...scan, occurrences: scan.occurrences.map((occurrence) => ({ ...occurrence, embed: false })) }]))
235
+ const resolved = resolveWorkspaceLinks({ repos: [{ name: repo.name, root, nodesByPath: new Map(nodes.map((node) => [node.path, node])) }], scanned: linkScans, isAssetEligible: () => false })
236
+ const localTargets = new Map(resolved.links.map((link) => [JSON.stringify([link.sourcePath, link.range.start]), link.target]))
237
+ // Recheck inventory and every selected byte, including explicit config/plan
238
+ // inputs. The graph and its source pins were derived from the same buffers.
239
+ if (canonicalize(census(root, git, scope).map((f) => f.rel)) !== canonicalize(files.map((f) => f.rel))) refuse('source-snapshot-changed')
240
+ for (const [rel, pin] of pins) if (hash(readBytes(root, rel)) !== pin.rawDigest) refuse('source-snapshot-changed')
241
+ if (canonicalize(observe(git)) !== canonicalize(before)) refuse('repository-snapshot-changed')
242
+ const snapshot = { repoId, head: before.head, tree: before.tree, dirty: before.dirty, scope, files: [...pins.values()].sort((a, b) => compare(a.path, b.path)) }
243
+ snapshot.digest = bodyDigest(snapshot)
244
+ const ref = (localId) => ({ repoId, localId })
245
+ const source = (rel, locator) => ({ repoId, path: rel, revision: snapshot.digest, digest: pins.get(rel).rawDigest, ...(locator ? { locator: { ...locator, ...(locator.range ? { rangeDigest: hash(contents.get(rel).subarray(locator.range.byteStart, locator.range.byteEnd)) } : {}) } } : {}) })
246
+ const fields = ['title', 'summary', 'path', 'extension', 'type', 'kgType', 'domain', 'lifecycle', 'status', 'audience', 'classification', 'tags']
247
+ const contributionNodes = nodes.map((node) => ({ ref: ref(node.id), source: source(node.path), ...Object.fromEntries(fields.map((key) => [key, node[key]])) }))
248
+ const ordinaryLinks = nodes.flatMap((node) => scanned.get(node).occurrences.map((occurrence) => {
249
+ const target = localTargets.get(JSON.stringify([node.path, occurrence.range.start]))
250
+ return { source: ref(node.id), target: target ? ref(target) : null, origin: 'ordinary-link', syntax: occurrence.syntax, embed: occurrence.embed, href: occurrence.href, fragment: occurrence.fragment, provenance: source(node.path, { range: occurrence.range, targetRange: occurrence.targetRange }), resolution: target ? 'resolved-local' : 'unresolved' }
251
+ }))
252
+ const declaredRelations = nodes.flatMap((node) => Object.entries(node.relations).flatMap(([predicate, targets]) => targets.map((target, index) => {
253
+ const written = metadata.get(node).kg.relations[predicate]
254
+ const originalIndex = Array.isArray(written) ? written.map((item, i) => ({ value: String(item).trim(), index: i })).filter((item) => item.value)[index].index : null
255
+ const pointer = `/kg/relations/${pointerToken(predicate)}${originalIndex !== null ? `/${originalIndex}` : ''}`
256
+ const range = locations.get(node).get(pointer)
257
+ if (!range) refuse('relation-source-location-unavailable')
258
+ return { source: ref(node.id), target, predicate, origin: 'declared', provenance: source(node.path, { pointer, range }) }
259
+ })))
260
+ const contribution = {
261
+ schema: REPO_CONTRIBUTION_SCHEMA, sourceProfile: PROFILE,
262
+ repository: { repoId, identity: { provider: repo.identity.provider, id: repo.identity.id, source: 'recorded-identity' }, aliases: [...new Set([repo.name, ...(repo.aliases ?? [])])].sort(compare) },
263
+ snapshot, inputs: { config: inputPaths.config ? source(inputPaths.config) : null, plan: inputPaths.plan ? source(inputPaths.plan) : null },
264
+ nodes: contributionNodes, ordinaryLinks, declaredRelations,
265
+ }
266
+ contribution.digest = bodyDigest(contribution)
267
+ const validation = validateRepositoryContribution(contribution)
268
+ if (!validation.ok) refuse('contribution-invalid')
269
+ return contribution
270
+ } catch (error) {
271
+ if (error instanceof RepositoryContributionError) throw error
272
+ refuse('source-unavailable')
273
+ }
274
+ }
275
+
276
+ /** Structural and digest integrity only; never source ownership or freshness. */
277
+ export function validateRepositoryContribution(value) {
278
+ const errors = []
279
+ const check = (ok, code) => { if (!ok) errors.push(code); return ok }
280
+ const keys = (v, required, optional = []) => plain(v) && required.every((key) => Object.hasOwn(v, key)) && Object.keys(v).every((key) => [...required, ...optional].includes(key))
281
+ try {
282
+ if (!check(keys(value, ['schema', 'sourceProfile', 'repository', 'snapshot', 'inputs', 'nodes', 'ordinaryLinks', 'declaredRelations', 'digest']), 'invalid-contribution-shape')) return { ok: false, errors }
283
+ check(value.schema === REPO_CONTRIBUTION_SCHEMA && value.sourceProfile === PROFILE, 'unsupported-contribution-schema')
284
+ const repository = value.repository
285
+ if (!check(keys(repository, ['repoId', 'identity', 'aliases']), 'invalid-repository-shape')) return { ok: false, errors }
286
+ check(keys(repository.identity, ['provider', 'id', 'source']) && repository.identity.provider === 'github' && text(repository.identity.id) && repository.identity.source === 'recorded-identity' && repository.repoId === identityKey(repository.identity), 'invalid-repository-identity')
287
+ check(Array.isArray(repository.aliases) && repository.aliases.every(text) && new Set(repository.aliases).size === repository.aliases.length, 'invalid-repository-aliases')
288
+ const snapshot = value.snapshot
289
+ if (!check(keys(snapshot, ['repoId', 'head', 'tree', 'dirty', 'scope', 'files', 'digest']), 'invalid-snapshot-shape')) return { ok: false, errors }
290
+ check(snapshot.repoId === repository.repoId && objectIdPattern.test(snapshot.head) && objectIdPattern.test(snapshot.tree) && typeof snapshot.dirty === 'boolean', 'invalid-snapshot-revision')
291
+ check(canonicalize(normalizeScope(snapshot.scope)) === canonicalize(snapshot.scope), 'invalid-snapshot-scope')
292
+ check(digestPattern.test(snapshot.digest) && snapshot.digest === bodyDigest(snapshot), 'snapshot-digest-mismatch')
293
+ if (!check(Array.isArray(snapshot.files), 'invalid-source-files')) return { ok: false, errors }
294
+ const files = new Map()
295
+ for (const file of snapshot.files) {
296
+ if (check(keys(file, ['path', 'rawDigest', 'byteLength']) && sourcePath(file.path) && digestPattern.test(file.rawDigest) && Number.isSafeInteger(file.byteLength) && file.byteLength >= 0 && !files.has(file.path), 'invalid-source-file')) files.set(file.path, file)
297
+ }
298
+ const validRef = (ref) => keys(ref, ['repoId', 'localId']) && ref.repoId === repository.repoId && text(ref.localId)
299
+ // Exact UTF-8 needs one to three bytes per UTF-16 code unit (a surrogate
300
+ // pair needs four bytes for two units). These are necessary structural
301
+ // bounds; only the receiving host's pinned bytes can prove exact offsets.
302
+ const distance = (units, bytes) => units >= 0 && bytes >= units && bytes <= units * 3
303
+ const range = (v, pin) => keys(v, ['start', 'end', 'byteStart', 'byteEnd']) && [v.start, v.end, v.byteStart, v.byteEnd].every(Number.isSafeInteger) && v.start >= 0 && v.start < v.end && v.byteStart >= 0 && v.byteStart < v.byteEnd && v.byteEnd <= pin.byteLength && distance(v.start, v.byteStart) && distance(v.end, v.byteEnd) && distance(v.end - v.start, v.byteEnd - v.byteStart)
304
+ const validSource = (s) => {
305
+ if (!keys(s, ['repoId', 'path', 'revision', 'digest'], ['locator']) || s.repoId !== repository.repoId || s.revision !== snapshot.digest || !files.has(s.path) || s.digest !== files.get(s.path).rawDigest) return false
306
+ if (!Object.hasOwn(s, 'locator')) return true
307
+ const loc = s.locator
308
+ return keys(loc, [], ['pointer', 'range', 'targetRange', 'rangeDigest']) && Object.keys(loc).length > 0 && (!Object.hasOwn(loc, 'pointer') || (text(loc.pointer) && loc.pointer.startsWith('/'))) && (!Object.hasOwn(loc, 'range') || (range(loc.range, files.get(s.path)) && digestPattern.test(loc.rangeDigest))) && (!Object.hasOwn(loc, 'targetRange') || (range(loc.targetRange, files.get(s.path)) && loc.range && loc.targetRange.start >= loc.range.start && loc.targetRange.end <= loc.range.end && loc.targetRange.byteStart >= loc.range.byteStart && loc.targetRange.byteEnd <= loc.range.byteEnd && distance(loc.targetRange.start - loc.range.start, loc.targetRange.byteStart - loc.range.byteStart) && distance(loc.range.end - loc.targetRange.end, loc.range.byteEnd - loc.targetRange.byteEnd))) && (!Object.hasOwn(loc, 'rangeDigest') || Object.hasOwn(loc, 'range'))
309
+ }
310
+ if (!check(Array.isArray(value.nodes) && Array.isArray(value.ordinaryLinks) && Array.isArray(value.declaredRelations), 'invalid-source-records')) return { ok: false, errors }
311
+ const nodeKeys = new Map()
312
+ const fields = ['ref', 'source', 'title', 'summary', 'path', 'extension', 'type', 'kgType', 'domain', 'lifecycle', 'status', 'audience', 'classification', 'tags']
313
+ for (const node of value.nodes) {
314
+ const valid = keys(node, fields) && validRef(node.ref) && validSource(node.source) && node.source.path === node.path && sourcePath(node.path) && fields.slice(2, -1).every((key) => typeof node[key] === 'string') && node.extension === 'md' && node.type === 'markdown' && VALID_KG_TYPES.has(node.kgType) && VALID_STATUSES.has(node.status) && VALID_AUDIENCES.has(node.audience) && node.classification === 'classified' && Array.isArray(node.tags) && node.tags.every(text)
315
+ if (check(valid, 'invalid-source-node')) {
316
+ check(!nodeKeys.has(nodeRefKey(node.ref)), 'duplicate-local-node-id')
317
+ nodeKeys.set(nodeRefKey(node.ref), node)
318
+ }
319
+ }
320
+ const sourceNode = (ref, source) => validRef(ref) && validSource(source) && nodeKeys.get(nodeRefKey(ref))?.path === source.path
321
+ for (const link of value.ordinaryLinks) check(keys(link, ['source', 'target', 'origin', 'syntax', 'embed', 'href', 'fragment', 'provenance', 'resolution']) && sourceNode(link.source, link.provenance) && link.origin === 'ordinary-link' && ['markdown', 'wikilink'].includes(link.syntax) && typeof link.embed === 'boolean' && text(link.href) && typeof link.fragment === 'string' && link.provenance.locator?.range && link.provenance.locator?.targetRange && (link.target === null ? link.resolution === 'unresolved' : validRef(link.target) && nodeKeys.has(nodeRefKey(link.target)) && link.resolution === 'resolved-local'), 'invalid-ordinary-link')
322
+ for (const relation of value.declaredRelations) check(keys(relation, ['source', 'target', 'predicate', 'origin', 'provenance']) && sourceNode(relation.source, relation.provenance) && text(relation.target) && VALID_RELATION_TYPES.has(relation.predicate) && relation.origin === 'declared' && relation.provenance.locator?.range && typeof relation.provenance.locator?.pointer === 'string' && new RegExp(`^/kg/relations/${pointerToken(relation.predicate)}(?:/[0-9]+)?$`).test(relation.provenance.locator.pointer), 'invalid-declared-relation')
323
+ check(keys(value.inputs, ['config', 'plan']) && [value.inputs.config, value.inputs.plan].every((s) => s === null || validSource(s)), 'invalid-input-pins')
324
+ check(digestPattern.test(value.digest) && value.digest === bodyDigest(value), 'contribution-digest-mismatch')
325
+ } catch { errors.push('invalid-contribution-data') }
326
+ return { ok: errors.length === 0, errors: [...new Set(errors)] }
327
+ }
328
+
329
+ /** Only the supplied set participates. No repository/name/nearest-match guess. */
330
+ export function resolveLegacyNodeRef(localId, contributions) {
331
+ if (!text(localId) || !Array.isArray(contributions)) refuse('invalid-legacy-node-ref')
332
+ const matches = []
333
+ const repos = new Set()
334
+ for (const contribution of contributions) {
335
+ if (!validateRepositoryContribution(contribution).ok) refuse('contribution-invalid')
336
+ if (repos.has(contribution.repository.repoId)) refuse('duplicate-repository-contribution')
337
+ repos.add(contribution.repository.repoId)
338
+ matches.push(...contribution.nodes.filter((node) => node.ref.localId === localId).map((node) => node.ref))
339
+ }
340
+ if (matches.length > 1) refuse('legacy-node-id-ambiguous-use-qualified-ref')
341
+ if (matches.length === 0) refuse('legacy-node-id-unresolved')
342
+ return structuredClone(matches[0])
343
+ }
344
+
345
+ /**
346
+ * Pure topology over exactly the supplied contributions and explicit layout.
347
+ * The caller owns access decisions. Missing/ambiguous endpoints stay null;
348
+ * no diagnostics, files, provider calls or inferred enrollment are consulted.
349
+ * @param {import('./repo-contribution.types.mjs').RepositoryContribution[]} contributions
350
+ * @param {{enrollment:{repoId:string, root:string}[]}} options
351
+ * @returns {import('./repo-contribution.types.mjs').ContributionTopology}
352
+ */
353
+ export function resolveContributionTopology(contributions, { enrollment } = {}) {
354
+ if (!Array.isArray(contributions) || !Array.isArray(enrollment)) refuse('invalid-contribution-enrollment')
355
+ const supplied = new Map()
356
+ for (const contribution of contributions) {
357
+ if (!validateRepositoryContribution(contribution).ok) refuse('contribution-invalid')
358
+ if (supplied.has(contribution.repository.repoId)) refuse('duplicate-repository-contribution')
359
+ supplied.set(contribution.repository.repoId, contribution)
360
+ }
361
+ const roots = new Map()
362
+ for (const entry of enrollment) {
363
+ if (!plain(entry) || Object.keys(entry).length !== 2 || !text(entry.repoId) || !supplied.has(entry.repoId) || roots.has(entry.repoId) || !(entry.root === '.' || relative(entry.root))) refuse('invalid-contribution-enrollment')
364
+ roots.set(entry.repoId, entry.root)
365
+ }
366
+ if (roots.size !== supplied.size) refuse('invalid-contribution-enrollment')
367
+ const virtualRoot = path.resolve(path.sep, '__atelier_contribution_enrollment__')
368
+ const byRef = new Map()
369
+ const byLocation = new Map()
370
+ const scanned = new Map()
371
+ const sorted = [...supplied.values()].sort((a, b) => compare(a.repository.repoId, b.repository.repoId))
372
+ const repos = sorted.map((contribution) => {
373
+ const repoId = contribution.repository.repoId
374
+ const root = path.resolve(virtualRoot, roots.get(repoId))
375
+ const nodesByPath = new Map()
376
+ for (const node of contribution.nodes) {
377
+ const key = nodeRefKey(node.ref)
378
+ const internal = { id: key, path: node.path, title: node.title }
379
+ nodesByPath.set(node.path, internal)
380
+ byRef.set(key, node.ref)
381
+ const location = path.resolve(root, node.path)
382
+ if (!byLocation.has(location)) byLocation.set(location, [])
383
+ byLocation.get(location).push(key)
384
+ // Source bytes were already scanned and pinned. Disabling only the
385
+ // resolver's asset fallback makes this invocation entirely free of I/O;
386
+ // the returned occurrence retains its original embed flag unchanged.
387
+ scanned.set(internal, { utf8Exact: true, occurrences: contribution.ordinaryLinks.filter((link) => nodeRefKey(link.source) === key).map((link) => ({ syntax: link.syntax, embed: false, href: link.href, fragment: link.fragment, range: link.provenance.locator.range, targetRange: link.provenance.locator.targetRange })) })
388
+ }
389
+ return { name: roots.get(repoId), root, nodesByPath }
390
+ })
391
+ const resolved = resolveWorkspaceLinks({ repos, scanned, isAssetEligible: () => false })
392
+ const targets = new Map()
393
+ for (const link of resolved.links) {
394
+ const candidates = byLocation.get(path.resolve(virtualRoot, link.targetRepo, link.targetPath))
395
+ if (candidates?.length === 1) targets.set(JSON.stringify([link.source, link.range.start]), byRef.get(link.target))
396
+ }
397
+ return {
398
+ nodes: structuredClone(sorted.flatMap((contribution) => contribution.nodes)),
399
+ ordinaryLinks: sorted.flatMap((contribution) => contribution.ordinaryLinks.map((link) => {
400
+ const target = targets.get(JSON.stringify([nodeRefKey(link.source), link.provenance.locator.range.start])) ?? null
401
+ return { ...structuredClone(link), target: target ? structuredClone(target) : null, resolution: target ? target.repoId === link.source.repoId ? 'resolved-local' : 'resolved-cross-repository' : 'unresolved' }
402
+ })),
403
+ declaredRelations: structuredClone(sorted.flatMap((contribution) => contribution.declaredRelations)),
404
+ }
405
+ }
@@ -0,0 +1,32 @@
1
+ /** Derived source DTOs. Digests establish integrity, never source ownership. */
2
+
3
+ /** @typedef {`sha256:${string}`} Digest */
4
+ /** @typedef {{repoId:string, localId:string}} NodeRef */
5
+ /** @typedef {{start:number, end:number, byteStart:number, byteEnd:number}} SourceRange */
6
+ /** @typedef {{repoId:string, path:string, revision:Digest, digest:Digest, locator?:{pointer?:string, range?:SourceRange, targetRange?:SourceRange, rangeDigest?:Digest}}} SourceRef */
7
+ /** @typedef {{base:string, include:string|null, exclude:string|null}} CensusScope */
8
+ /** @typedef {{path:string, rawDigest:Digest, byteLength:number}} SourceFilePin */
9
+ /** @typedef {{repoId:string, head:string, tree:string, dirty:boolean, scope:CensusScope, files:SourceFilePin[], digest:Digest}} RepositorySnapshot */
10
+ /** @typedef {{ref:NodeRef, source:SourceRef, title:string, summary:string, path:string, extension:'md', type:'markdown', kgType:string, domain:string, lifecycle:string, status:string, audience:string, classification:'classified', tags:string[]}} ContributionNode */
11
+ /** @typedef {{source:NodeRef, target:NodeRef|null, origin:'ordinary-link', syntax:'markdown'|'wikilink', embed:boolean, href:string, fragment:string, provenance:SourceRef, resolution:'resolved-local'|'unresolved'}} OrdinaryLinkOccurrence */
12
+ /** @typedef {{source:NodeRef, target:string, predicate:string, origin:'declared', provenance:SourceRef}} DeclaredRelation */
13
+ /**
14
+ * A source projection, not a permission decision or accepted semantic assertion.
15
+ * revision is snapshot.digest, including the selected dirty working-tree bytes.
16
+ * rangeDigest pins exactly the raw byte slice described by locator.range.
17
+ * @typedef {object} RepositoryContribution
18
+ * @property {'atelier.repo-contribution@v1'} schema
19
+ * @property {'markdown-explicit-id/v1'} sourceProfile
20
+ * @property {{repoId:string, identity:{provider:'github', id:string, source:'recorded-identity'}, aliases:string[]}} repository
21
+ * @property {RepositorySnapshot} snapshot
22
+ * @property {{config:SourceRef|null, plan:SourceRef|null}} inputs
23
+ * @property {ContributionNode[]} nodes
24
+ * @property {OrdinaryLinkOccurrence[]} ordinaryLinks
25
+ * @property {DeclaredRelation[]} declaredRelations
26
+ * @property {Digest} digest
27
+ */
28
+ /** @typedef {{scope?:Partial<CensusScope>, inputPaths?:{config?:string, plan?:string}}} SourceBuilderOptions */
29
+ /** @typedef {Omit<OrdinaryLinkOccurrence, 'resolution'> & {resolution:'resolved-local'|'resolved-cross-repository'|'unresolved'}} ResolvedOrdinaryLinkOccurrence */
30
+ /** @typedef {{nodes:ContributionNode[], ordinaryLinks:ResolvedOrdinaryLinkOccurrence[], declaredRelations:DeclaredRelation[]}} ContributionTopology */
31
+
32
+ export {}
@@ -0,0 +1,41 @@
1
+ import { canonicalize } from '../attestation/jcs.mjs'
2
+ import { immutable } from '../edge-policy/contract.mjs'
3
+ import { displayEligibleEdge } from '../edge-policy/policy.mjs'
4
+
5
+ const refused = Object.freeze({ eligible: false })
6
+ const same = (a, b) => canonicalize(a) === canonicalize(b)
7
+ const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value)
8
+ const boundedList = (value, max, itemMax) => Array.isArray(value) && value.length <= max && value.every(item => text(item, itemMax))
9
+
10
+ /**
11
+ * Convert an original in-process policy decision into the graph-access edge port
12
+ * result. The host still owns recipient access, snapshot binding and freshness.
13
+ * A display projection or serialized decision cannot stand in for its original.
14
+ */
15
+ export function adaptEdgeDecision(candidate, decision) {
16
+ try {
17
+ // This call is the policy owner's WeakSet provenance and active-state check.
18
+ // Do not clone the decision or trust a candidate's eligible/accepted fields.
19
+ const display = displayEligibleEdge(decision)
20
+ if (!display || !candidate || candidate.edgeId !== display.edgeId ||
21
+ !same(candidate.source, display.source) || !same(candidate.target, display.target) ||
22
+ !same(candidate.supportRefs, display.supportRefs)) return refused
23
+
24
+ // Keep complete qualifications and ordered evidence occurrences. Refuse a
25
+ // policy result outside the receiving port's bounds rather than clipping it.
26
+ if (!text(display.predicate, 128) || !boundedList(display.supportRefs, 64, 512) ||
27
+ display.supportRefs.length === 0 || new Set(display.supportRefs).size !== display.supportRefs.length ||
28
+ !display.supportRefs.includes(display.sourceRef) || !boundedList(display.qualifications, 64, 2048) ||
29
+ !boundedList(display.reviewRefs, 64, 2048)) return refused
30
+
31
+ return immutable({ edgeId: display.edgeId, source: display.source, target: display.target,
32
+ predicate: display.family === 'hyperlink' && display.predicate === 'links-to' ? 'links_to' : display.predicate,
33
+ family: display.family, acceptanceState: display.acceptanceState, supportRefs: [...display.supportRefs],
34
+ sourceRef: display.sourceRef, assurance: display.assurance, qualifications: [...display.qualifications],
35
+ reviewRefs: [...display.reviewRefs], recordDigest: display.recordDigest, policyDigest: display.policyDigest,
36
+ eligible: true })
37
+ } catch {
38
+ // Refusals disclose no candidate, audit history, source paths or port errors.
39
+ return refused
40
+ }
41
+ }
@@ -0,0 +1,73 @@
1
+ // Bind a supplied policy record to source pins before a host digests its candidate.
2
+ import { canonicalize } from '../attestation/jcs.mjs'
3
+ import { edgeIdentity, immutable } from '../edge-policy/contract.mjs'
4
+ import { edgeEvidenceOccurrences } from '../edge-policy/policy.mjs'
5
+ import { sourceCitationFromProvenance } from '../edge-policy/source-citation-adapter.mjs'
6
+ import { resolveLegacyNodeRef } from '../graph/repo-contribution.mjs'
7
+ import { adaptRepositoryContribution } from './source-adapter.mjs'
8
+
9
+ const invalid = 'Invalid graph access edge record input'
10
+ const external = 'Cross-repository edge record binding is unsupported'
11
+ const oversized = 'Edge record exceeds graph access bounds'
12
+ const need = (value, message = invalid) => { if (!value) throw new TypeError(message) }
13
+ const same = (a, b) => canonicalize(a) === canonicalize(b)
14
+ const key = ref => canonicalize([ref.repoId, ref.localId])
15
+
16
+ function bindCitation(contribution, citation, kind) {
17
+ const matches = [...contribution.ordinaryLinks, ...contribution.declaredRelations].filter(observed => {
18
+ const pin = observed.provenance
19
+ const range = pin.locator?.range
20
+ return (!kind || observed.origin === kind) && pin.repoId === citation.repoId && pin.path === citation.path &&
21
+ pin.revision === citation.revision && pin.digest === citation.fileDigest &&
22
+ range?.byteStart === citation.locator.byteStart && range?.byteEnd === citation.locator.byteEnd &&
23
+ same(sourceCitationFromProvenance(contribution, pin), citation)
24
+ })
25
+ need(matches.length === 1)
26
+ return matches[0]
27
+ }
28
+
29
+ /**
30
+ * Return one complete, digestable graph-access candidate containing exactly the
31
+ * supplied policy record. No record, approval, grant or verification is created.
32
+ * ownerRef must come from the trusted host. Home pin presence is structural;
33
+ * current placement, custody, bytes, origin and acceptance remain host checks.
34
+ * This bounded adapter accepts one contribution only. All endpoints and source
35
+ * evidence must bind to it; cross-repository assertions await an explicit
36
+ * multi-contribution binding and evidence-distribution contract.
37
+ */
38
+ export function adaptEdgeRecord(record, contribution, options = {}) {
39
+ try {
40
+ need(options && typeof options === 'object' && Object.keys(options).every(name => name === 'ownerRef'))
41
+ const candidate = adaptRepositoryContribution(contribution, options)
42
+ // Derive the owner's exact identities before the host hashes any candidate.
43
+ const { edgeId } = edgeIdentity(record)
44
+ const occurrences = edgeEvidenceOccurrences(record)
45
+ const repoId = contribution.repository.repoId
46
+ need([record.home, record.subject, record.object, ...occurrences.map(value => value.source)]
47
+ .every(value => value.repoId === repoId), external)
48
+ need(occurrences.length <= 64 && record.predicate.length <= 128, oversized)
49
+ const nodes = new Set(contribution.nodes.map(node => key(node.ref)))
50
+ need(nodes.has(key(record.subject)) && nodes.has(key(record.object)))
51
+ const home = contribution.snapshot.files.find(file => file.path === record.home.path)
52
+ need(home)
53
+ const originKind = record.family === 'hyperlink' ? 'ordinary-link' : record.family === 'source-declaration' ? 'declared' : undefined
54
+ const origin = bindCitation(contribution, record.origin, originKind)
55
+ if (record.family === 'hyperlink') need(record.predicate === 'links-to' &&
56
+ same(origin.source, record.subject) && origin.target !== null && same(origin.target, record.object))
57
+ if (record.family === 'source-declaration') need(same(origin.source, record.subject) && origin.predicate === record.predicate &&
58
+ same(resolveLegacyNodeRef(origin.target, [contribution]), record.object))
59
+ const evidence = occurrences.map((occurrence, index) => ({
60
+ evidenceId: occurrence.id,
61
+ sourceRef: (index === 0 ? origin : bindCitation(contribution, occurrence.source)).provenance,
62
+ }))
63
+ const sourceRef = { repoId, path: home.path, revision: contribution.snapshot.digest, digest: home.rawDigest }
64
+ return immutable(structuredClone({ ...candidate,
65
+ edges: [{ edgeId, source: record.subject, target: record.object, predicate: record.predicate,
66
+ family: record.family, sourceRef, supportRefs: occurrences.map(value => value.id) }],
67
+ evidence,
68
+ }))
69
+ } catch (error) {
70
+ if (error instanceof TypeError && [invalid, external, oversized].includes(error.message)) throw error
71
+ throw new TypeError(invalid)
72
+ }
73
+ }