@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.
- package/CHANGELOG.md +283 -0
- package/README.md +102 -52
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +197 -11
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +9 -9
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +49 -21
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +431 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +96 -8
- package/src/projection/project.mjs +21 -16
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +42 -11
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Graph adapter over host-owned access decisions. No authentication store.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
4
|
+
import { deepFreeze } from '../composition/wire.mjs'
|
|
5
|
+
|
|
6
|
+
const hash = value => createHash('sha256').update(canonicalize(value)).digest('hex')
|
|
7
|
+
const sourceDigest = value => `sha256:${hash(value)}`
|
|
8
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
9
|
+
const digest = value => typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value)
|
|
10
|
+
const revision = value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)
|
|
11
|
+
const string = (value, max = 512) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f]/u.test(value)
|
|
12
|
+
const equal = (a, b) => canonicalize(a) === canonicalize(b)
|
|
13
|
+
const key = ref => canonicalize([ref.repoId, ref.localId])
|
|
14
|
+
const ref = value => value && string(value.repoId) && string(value.localId)
|
|
15
|
+
const path = value => string(value, 2048) && !value.includes('\\') && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && !value.split('/').some(p => ['', '.', '..'].includes(p))
|
|
16
|
+
const list = (value, max = 10000) => Array.isArray(value) && value.length <= max
|
|
17
|
+
const need = value => { if (!value) throw new GraphAccessRefusal() }
|
|
18
|
+
const immutable = value => deepFreeze(structuredClone(value))
|
|
19
|
+
const validScope = s => s && typeof s === 'object' && !Array.isArray(s) &&
|
|
20
|
+
Object.keys(s).sort().join(',') === 'base,exclude,include' && (s.base === '' || s.base === '.' || path(s.base)) &&
|
|
21
|
+
[s.include, s.exclude].every(v => v === null || string(v, 2048))
|
|
22
|
+
|
|
23
|
+
export class GraphAccessRefusal extends Error {
|
|
24
|
+
constructor() { super('Graph access unavailable'); this.name = 'GraphAccessRefusal' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function bounded(value) {
|
|
28
|
+
// The adapter accepts JSON data from a trusted loader, never browser bundles.
|
|
29
|
+
// Existing canonicalization refuses undefined, classes and nonfinite values.
|
|
30
|
+
const bytes = canonicalize(value)
|
|
31
|
+
need(Buffer.byteLength(bytes) <= 8 * 1024 * 1024)
|
|
32
|
+
return immutable(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Pin the complete normalized candidate using the existing JCS serializer. */
|
|
36
|
+
export function digestAccessCandidate(value) { return sourceDigest(bounded(value)) }
|
|
37
|
+
|
|
38
|
+
function validateDecision(input, operation, now) {
|
|
39
|
+
const d = bounded(input)
|
|
40
|
+
need(d.operation === operation && ['read', 'export'].includes(operation))
|
|
41
|
+
for (const k of ['principalRef', 'proofRef', 'grantRevision', 'policyRevision']) need(string(d[k]))
|
|
42
|
+
need(Number.isSafeInteger(d.expiresAt) && d.expiresAt > now)
|
|
43
|
+
need(list(d.repositories, 64))
|
|
44
|
+
const seen = new Set()
|
|
45
|
+
for (const r of d.repositories) {
|
|
46
|
+
need(string(r.repoId) && !seen.has(r.repoId) && string(r.ownerRef))
|
|
47
|
+
need(revision(r.head) && revision(r.tree) && digest(r.snapshotDigest) && digest(r.contributionDigest))
|
|
48
|
+
need(validScope(r.scope))
|
|
49
|
+
seen.add(r.repoId)
|
|
50
|
+
}
|
|
51
|
+
return d
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function snapshotFor(c, approved) {
|
|
55
|
+
const s = c.snapshot
|
|
56
|
+
need(c.repository.repoId === approved.repoId && c.repository.ownerRef === approved.ownerRef)
|
|
57
|
+
need(s.repoId === approved.repoId && s.head === approved.head && s.tree === approved.tree && s.digest === approved.snapshotDigest)
|
|
58
|
+
need(validScope(s.scope) && equal(s.scope, approved.scope) && typeof s.dirty === 'boolean' && list(s.files))
|
|
59
|
+
const seen = new Set()
|
|
60
|
+
for (const f of s.files) {
|
|
61
|
+
need(path(f.path) && digest(f.rawDigest) && Number.isSafeInteger(f.byteLength) && f.byteLength >= 0 && !seen.has(f.path))
|
|
62
|
+
seen.add(f.path)
|
|
63
|
+
}
|
|
64
|
+
return { repoId: approved.repoId, head: s.head, tree: s.tree, dirty: s.dirty, scope: s.scope,
|
|
65
|
+
files: s.files.map(f => ({ path: f.path, rawDigest: f.rawDigest, byteLength: f.byteLength })).sort((a, b) => compare(a.path, b.path)), digest: s.digest }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validLocator(value) {
|
|
69
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
70
|
+
if (!Object.keys(value).every(k => ['pointer', 'range', 'targetRange', 'rangeDigest'].includes(k))) return false
|
|
71
|
+
if (value.rangeDigest !== undefined && (!digest(value.rangeDigest) || !value.range || !Number.isSafeInteger(value.range.byteStart) || !Number.isSafeInteger(value.range.byteEnd))) return false
|
|
72
|
+
if (value.pointer !== undefined && (typeof value.pointer !== 'string' || value.pointer.length > 1024 || (value.pointer !== '' && !value.pointer.startsWith('/')))) return false
|
|
73
|
+
for (const k of ['range', 'targetRange']) {
|
|
74
|
+
const r = value[k]
|
|
75
|
+
if (r === undefined) continue
|
|
76
|
+
if (!r || !Object.keys(r).every(k => ['start', 'end', 'byteStart', 'byteEnd'].includes(k))) return false
|
|
77
|
+
if (![r.start, r.end].every(n => Number.isSafeInteger(n) && n >= 0) || r.end < r.start) return false
|
|
78
|
+
if ((r.byteStart === undefined) !== (r.byteEnd === undefined)) return false
|
|
79
|
+
if (r.byteStart !== undefined && (![r.byteStart, r.byteEnd].every(n => Number.isSafeInteger(n) && n >= 0) || r.byteEnd < r.byteStart)) return false
|
|
80
|
+
}
|
|
81
|
+
return Object.keys(value).length > 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function source(value, snapshots) {
|
|
85
|
+
if (!value || !path(value.path) || !string(value.repoId) || !digest(value.revision) || !digest(value.digest)) return null
|
|
86
|
+
const s = snapshots.get(value.repoId)
|
|
87
|
+
const file = s?.files.find(f => f.path === value.path && f.rawDigest === value.digest)
|
|
88
|
+
if (!s || value.revision !== s.digest || !file) return null
|
|
89
|
+
if (value.locator !== undefined && !validLocator(value.locator)) return null
|
|
90
|
+
for (const range of [value.locator?.range, value.locator?.targetRange]) {
|
|
91
|
+
if (range && (range.end > file.byteLength || (range.byteEnd !== undefined && range.byteEnd > file.byteLength))) return null
|
|
92
|
+
}
|
|
93
|
+
return { repoId: value.repoId, path: value.path, revision: value.revision, digest: value.digest,
|
|
94
|
+
...(value.locator === undefined ? {} : { locator: value.locator }) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function projectedNode(n, repoId, snapshots) {
|
|
98
|
+
if (!ref(n.ref) || n.ref.repoId !== repoId || n.displayEligible === false) return null
|
|
99
|
+
const pin = source(n.sourceRef, snapshots)
|
|
100
|
+
if (!pin || pin.repoId !== repoId) return null
|
|
101
|
+
for (const k of ['sourceKind', 'title', 'classification', 'lifecycle']) if (!string(n[k], k === 'title' ? 1024 : 128)) return null
|
|
102
|
+
if (typeof n.summary !== 'string' || n.summary.length > 8192 || !list(n.tags, 64) || !n.tags.every(t => string(t, 256))) return null
|
|
103
|
+
return { ref: { repoId, localId: n.ref.localId }, sourceKind: n.sourceKind, title: n.title, summary: n.summary,
|
|
104
|
+
classification: n.classification, lifecycle: n.lifecycle, tags: [...new Set(n.tags)].sort(), sourceRef: pin, displayEligible: true }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Trusted host ports establish principal, grants, source ownership and freshness.
|
|
109
|
+
* A local host may do this only over its operator's deliberately permitted clones.
|
|
110
|
+
* Audience, readBoundary, Git authors and browser claims never supply these ports.
|
|
111
|
+
* The contribution is a normalized projection pinned by the decision's digest;
|
|
112
|
+
* verifyContribution must check the owning producer's exact bytes and scope,
|
|
113
|
+
* including any locator.rangeDigest over its exact raw byte slice.
|
|
114
|
+
*/
|
|
115
|
+
export function createGraphAccess(ports, { now = Date.now } = {}) {
|
|
116
|
+
for (const name of ['resolveAccess', 'loadContribution', 'verifyContribution', 'evaluateEdge', 'revalidate']) need(typeof ports?.[name] === 'function')
|
|
117
|
+
need(typeof now === 'function')
|
|
118
|
+
const handles = new WeakMap()
|
|
119
|
+
const time = () => { const n = now(); need(Number.isSafeInteger(n)); return n }
|
|
120
|
+
const live = (state, signal) => {
|
|
121
|
+
try { need(!signal?.aborted && !state.invalid && state.decision.expiresAt > time()) }
|
|
122
|
+
catch { state.invalid = true; state.graph = null; throw new GraphAccessRefusal() }
|
|
123
|
+
}
|
|
124
|
+
const fresh = async (state, signal) => {
|
|
125
|
+
try {
|
|
126
|
+
live(state, signal)
|
|
127
|
+
need(await ports.revalidate(state.decision, signal) === true)
|
|
128
|
+
live(state, signal)
|
|
129
|
+
} catch { state.invalid = true; state.graph = null; throw new GraphAccessRefusal() }
|
|
130
|
+
}
|
|
131
|
+
const stateFor = binding => { const s = handles.get(binding); need(s); return s }
|
|
132
|
+
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
async loadAuthorizedGraph(context, { operation = 'read', signal } = {}) {
|
|
135
|
+
let state
|
|
136
|
+
try {
|
|
137
|
+
need(['read', 'export'].includes(operation) && !signal?.aborted)
|
|
138
|
+
const decision = validateDecision(await ports.resolveAccess(context, { operation, signal }), operation, time())
|
|
139
|
+
state = { decision, invalid: false }
|
|
140
|
+
await fresh(state, signal); live(state, signal)
|
|
141
|
+
const contributions = [], snapshots = new Map(), diagnostics = []
|
|
142
|
+
// Only the approved set is enumerated. A denied repo cannot affect loader
|
|
143
|
+
// calls, raw exceptions, counts, diagnostics, cache keys or search input.
|
|
144
|
+
for (const approved of [...decision.repositories].sort((a, b) => compare(a.repoId, b.repoId))) {
|
|
145
|
+
await fresh(state, signal); live(state, signal)
|
|
146
|
+
try {
|
|
147
|
+
const c = bounded(await ports.loadContribution(approved, { operation, signal }))
|
|
148
|
+
need(sourceDigest(c) === approved.contributionDigest)
|
|
149
|
+
need(await ports.verifyContribution(c, approved, { operation, signal }) === true)
|
|
150
|
+
const snapshot = snapshotFor(c, approved)
|
|
151
|
+
need(list(c.nodes) && list(c.edges) && list(c.evidence))
|
|
152
|
+
// Intrinsic records must belong to this exact verified contribution.
|
|
153
|
+
// A stale/invalid authorized record makes its source incomplete;
|
|
154
|
+
// do not silently call the remaining subset a complete snapshot.
|
|
155
|
+
const localSnapshots = new Map([[approved.repoId, snapshot]])
|
|
156
|
+
for (const n of c.nodes) need(n?.displayEligible === false || projectedNode(n, approved.repoId, localSnapshots))
|
|
157
|
+
for (const e of c.evidence) {
|
|
158
|
+
const pin = source(e?.sourceRef, localSnapshots)
|
|
159
|
+
need(string(e?.evidenceId) && pin?.repoId === approved.repoId)
|
|
160
|
+
}
|
|
161
|
+
snapshots.set(approved.repoId, snapshot)
|
|
162
|
+
contributions.push(c)
|
|
163
|
+
} catch {
|
|
164
|
+
diagnostics.push({ code: 'authorized-source-unavailable', severity: 'warning', repoId: approved.repoId,
|
|
165
|
+
nodeRefs: [], evidenceRefs: [], action: 'Refresh the authorized source.' })
|
|
166
|
+
}
|
|
167
|
+
await fresh(state, signal); live(state, signal)
|
|
168
|
+
}
|
|
169
|
+
const nodes = [], evidence = [], edges = [], nodeKeys = new Set(), evidenceIds = new Set(), edgeIds = new Set()
|
|
170
|
+
for (const c of contributions) {
|
|
171
|
+
for (const raw of c.nodes) {
|
|
172
|
+
const n = projectedNode(raw, c.repository.repoId, snapshots)
|
|
173
|
+
if (!n) continue
|
|
174
|
+
need(!nodeKeys.has(key(n.ref))); nodeKeys.add(key(n.ref)); nodes.push(n)
|
|
175
|
+
}
|
|
176
|
+
for (const raw of c.evidence) {
|
|
177
|
+
const pin = source(raw.sourceRef, snapshots)
|
|
178
|
+
if (!string(raw.evidenceId) || !pin || pin.repoId !== c.repository.repoId) continue
|
|
179
|
+
need(!evidenceIds.has(raw.evidenceId)); evidenceIds.add(raw.evidenceId)
|
|
180
|
+
evidence.push({ evidenceId: raw.evidenceId, sourceRef: pin, verification: 'verified-source' })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const c of contributions) for (const raw of c.edges) {
|
|
184
|
+
// Cross-repository topology disappears before the policy port runs.
|
|
185
|
+
if (!string(raw.edgeId) || !ref(raw.source) || !ref(raw.target) || !nodeKeys.has(key(raw.source)) || !nodeKeys.has(key(raw.target))) continue
|
|
186
|
+
const home = source(raw.sourceRef, snapshots)
|
|
187
|
+
if (!home || home.repoId !== c.repository.repoId || !list(raw.supportRefs, 64) || !raw.supportRefs.every(id => evidenceIds.has(id))) continue
|
|
188
|
+
if (new Set(raw.supportRefs).size !== raw.supportRefs.length) continue
|
|
189
|
+
await fresh(state, signal); live(state, signal)
|
|
190
|
+
// The edge owner verifies its immutable record, signatures and policy.
|
|
191
|
+
// A contribution's eligible/accepted flags have no authority.
|
|
192
|
+
const result = bounded(await ports.evaluateEdge(raw, decision, { signal }))
|
|
193
|
+
if (result.eligible !== true || result.edgeId !== raw.edgeId || !equal(result.source, raw.source) || !equal(result.target, raw.target) || !equal(result.supportRefs, raw.supportRefs)) continue
|
|
194
|
+
if (!string(result.predicate, 128) || !['hyperlink', 'source-declaration', 'reviewed-assertion'].includes(result.family)) continue
|
|
195
|
+
if (result.acceptanceState !== 'active' || result.supportRefs.length === 0 || !result.supportRefs.includes(result.sourceRef)) continue
|
|
196
|
+
if (!['navigation', 'source-declaration', 'reviewed-assertion'].includes(result.assurance) || !digest(result.recordDigest) || !digest(result.policyDigest)) continue
|
|
197
|
+
if (!list(result.qualifications, 64) || !result.qualifications.every(q => string(q, 2048))) continue
|
|
198
|
+
if (!list(result.reviewRefs, 64) || !result.reviewRefs.every(r => string(r, 2048))) continue
|
|
199
|
+
need(!edgeIds.has(result.edgeId)); edgeIds.add(result.edgeId)
|
|
200
|
+
edges.push({ edgeId: result.edgeId, source: { repoId: raw.source.repoId, localId: raw.source.localId },
|
|
201
|
+
target: { repoId: raw.target.repoId, localId: raw.target.localId }, predicate: result.predicate, family: result.family,
|
|
202
|
+
acceptanceState: 'active', supportRefs: [...result.supportRefs], sourceRef: result.sourceRef, eligible: true,
|
|
203
|
+
assurance: result.assurance, qualifications: [...result.qualifications], reviewRefs: [...result.reviewRefs],
|
|
204
|
+
recordDigest: result.recordDigest, policyDigest: result.policyDigest })
|
|
205
|
+
}
|
|
206
|
+
nodes.sort((a, b) => compare(key(a.ref), key(b.ref)))
|
|
207
|
+
edges.sort((a, b) => compare(a.edgeId, b.edgeId))
|
|
208
|
+
evidence.sort((a, b) => compare(a.evidenceId, b.evidenceId))
|
|
209
|
+
const sourceSnapshot = { repositories: [...snapshots.values()].map(s => {
|
|
210
|
+
const c = contributions.find(c => c.repository.repoId === s.repoId)
|
|
211
|
+
const input = name => {
|
|
212
|
+
const pin = source(c.inputs?.[name], snapshots)
|
|
213
|
+
return pin?.repoId === s.repoId ? pin : null
|
|
214
|
+
}
|
|
215
|
+
return { ...s, inputs: { config: input('config'), plan: input('plan') } }
|
|
216
|
+
}) }
|
|
217
|
+
// Source manifests may themselves mention denied targets. Only exact
|
|
218
|
+
// approved files/scope are admitted by the host; arbitrary diagnostics,
|
|
219
|
+
// relation arrays, search terms and source-wide summaries are not copied.
|
|
220
|
+
const graph = immutable({ producerVersion: 'atelier-graph-access/proposal-v1', sourceSnapshot,
|
|
221
|
+
accessContext: { proofRef: decision.proofRef, authorizedRepoIds: decision.repositories.map(r => r.repoId).sort(),
|
|
222
|
+
grantRevision: decision.grantRevision, policyRevision: decision.policyRevision, expiresAt: decision.expiresAt, filter: 'repository-grants' },
|
|
223
|
+
nodes, edges, evidence, questions: [], diagnostics,
|
|
224
|
+
completeness: diagnostics.length === 0 ? 'complete' : 'incomplete',
|
|
225
|
+
counts: { nodes: nodes.length, edges: edges.length, evidence: evidence.length },
|
|
226
|
+
capabilities: { read: true, export: operation === 'export' } })
|
|
227
|
+
const cacheKey = hash({ principalRef: decision.principalRef, proofRef: decision.proofRef, operation,
|
|
228
|
+
grantRevision: decision.grantRevision, policyRevision: decision.policyRevision, expiresAt: decision.expiresAt,
|
|
229
|
+
repositories: [...decision.repositories].sort((a, b) => compare(a.repoId, b.repoId)), graphDigest: hash(graph) })
|
|
230
|
+
const binding = Object.freeze({ cacheKey, expiresAt: decision.expiresAt })
|
|
231
|
+
state.graph = graph; state.operation = operation; handles.set(binding, state)
|
|
232
|
+
await fresh(state, signal); live(state, signal)
|
|
233
|
+
return Object.freeze({ graph, binding })
|
|
234
|
+
} catch { if (state) { state.invalid = true; state.graph = null }; throw new GraphAccessRefusal() }
|
|
235
|
+
},
|
|
236
|
+
async revalidate(binding, { signal } = {}) {
|
|
237
|
+
try { const state = stateFor(binding); await fresh(state, signal); live(state, signal); return true } catch { return false }
|
|
238
|
+
},
|
|
239
|
+
invalidate(binding) {
|
|
240
|
+
try { const state = stateFor(binding); state.invalid = true; state.graph = null; return true } catch { return false }
|
|
241
|
+
},
|
|
242
|
+
async guardArtifact(binding, graph, { operation = 'read', signal } = {}) {
|
|
243
|
+
try {
|
|
244
|
+
const state = stateFor(binding)
|
|
245
|
+
// Object identity prevents a copied/mixed artifact or caller label from
|
|
246
|
+
// becoming a reader-specific graph. Export needs a fresh export decision.
|
|
247
|
+
need(graph === state.graph && operation === state.operation)
|
|
248
|
+
await fresh(state, signal); live(state, signal)
|
|
249
|
+
return graph
|
|
250
|
+
} catch { throw new GraphAccessRefusal() }
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Recipient-scoped structural binding; the host supplies current grants and records.
|
|
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 multi-contribution edge record input'
|
|
10
|
+
const missing = 'Required edge record repository is not selected'
|
|
11
|
+
const oversized = 'Edge record exceeds graph access bounds'
|
|
12
|
+
const need = (value, message = invalid) => { if (!value) throw new TypeError(message) }
|
|
13
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
14
|
+
const same = (a, b) => canonicalize(a) === canonicalize(b)
|
|
15
|
+
const key = ref => canonicalize([ref.repoId, ref.localId])
|
|
16
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
17
|
+
|
|
18
|
+
function observedCitation(contribution, citation, kind) {
|
|
19
|
+
const matches = [...contribution.ordinaryLinks, ...contribution.declaredRelations].filter(observed => {
|
|
20
|
+
const pin = observed.provenance, range = pin.locator?.range
|
|
21
|
+
return (!kind || observed.origin === kind) && pin.repoId === citation.repoId && pin.path === citation.path &&
|
|
22
|
+
pin.revision === citation.revision && pin.digest === citation.fileDigest &&
|
|
23
|
+
range?.byteStart === citation.locator.byteStart && range?.byteEnd === citation.locator.byteEnd &&
|
|
24
|
+
same(sourceCitationFromProvenance(contribution, pin), citation)
|
|
25
|
+
})
|
|
26
|
+
need(matches.length === 1)
|
|
27
|
+
return matches[0]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize one supplied full policy record over an explicitly selected set of
|
|
32
|
+
* native contributions and trusted ownerRefs. The caller must select permitted
|
|
33
|
+
* records AFTER checking current recipient grants. This pure function never
|
|
34
|
+
* authenticates a grant, accepts an assertion or verifies source bytes/custody.
|
|
35
|
+
* A missing dependency refuses the whole binding before any candidate returns.
|
|
36
|
+
* Rebuild the whole binding when grants change; never retain a partial slice of
|
|
37
|
+
* a previously authorized bundle. Every candidate must be complete before the
|
|
38
|
+
* host computes its contribution digest and invokes createGraphAccess.
|
|
39
|
+
* Optional topology must cover exactly the selected contribution set. Without
|
|
40
|
+
* it, unresolved cross-repository hyperlinks refuse rather than guess targets.
|
|
41
|
+
*/
|
|
42
|
+
export function adaptMultiContributionRecord(record, selected, options = {}) {
|
|
43
|
+
try {
|
|
44
|
+
need(plain(options) && Object.keys(options).every(name => name === 'topology'))
|
|
45
|
+
need(Array.isArray(selected) && selected.length > 0)
|
|
46
|
+
need(selected.length <= 64, oversized)
|
|
47
|
+
const { edgeId } = edgeIdentity(record)
|
|
48
|
+
const occurrences = edgeEvidenceOccurrences(record)
|
|
49
|
+
need(occurrences.length <= 64 && record.predicate.length <= 128, oversized)
|
|
50
|
+
const contributions = new Map(), candidates = new Map()
|
|
51
|
+
for (const entry of selected) {
|
|
52
|
+
need(plain(entry) && Object.keys(entry).sort().join(',') === 'contribution,ownerRef')
|
|
53
|
+
const candidate = adaptRepositoryContribution(entry.contribution, { ownerRef: entry.ownerRef })
|
|
54
|
+
const repoId = candidate.repository.repoId
|
|
55
|
+
need(!contributions.has(repoId))
|
|
56
|
+
contributions.set(repoId, entry.contribution)
|
|
57
|
+
candidates.set(repoId, candidate)
|
|
58
|
+
}
|
|
59
|
+
need([record.home, record.subject, record.object, ...occurrences.map(value => value.source)]
|
|
60
|
+
.every(value => contributions.has(value.repoId)), missing)
|
|
61
|
+
if (options.topology !== undefined) {
|
|
62
|
+
const topology = options.topology
|
|
63
|
+
need(plain(topology) && Array.isArray(topology.nodes) && Array.isArray(topology.ordinaryLinks))
|
|
64
|
+
need(topology.nodes.length === [...contributions.values()].reduce((total, value) => total + value.nodes.length, 0) &&
|
|
65
|
+
topology.nodes.every(node => contributions.has(node?.ref?.repoId)) &&
|
|
66
|
+
topology.ordinaryLinks.every(link => contributions.has(link?.source?.repoId)))
|
|
67
|
+
for (const entry of selected) candidates.set(entry.contribution.repository.repoId,
|
|
68
|
+
adaptRepositoryContribution(entry.contribution, { ownerRef: entry.ownerRef, topology }))
|
|
69
|
+
}
|
|
70
|
+
const nodeRefs = new Set([...contributions.values()].flatMap(value => value.nodes.map(node => key(node.ref))))
|
|
71
|
+
need(nodeRefs.has(key(record.subject)) && nodeRefs.has(key(record.object)))
|
|
72
|
+
const homeContribution = contributions.get(record.home.repoId)
|
|
73
|
+
const home = homeContribution.snapshot.files.find(file => file.path === record.home.path)
|
|
74
|
+
need(home)
|
|
75
|
+
const originContribution = contributions.get(record.origin.repoId)
|
|
76
|
+
const kind = record.family === 'hyperlink' ? 'ordinary-link' : record.family === 'source-declaration' ? 'declared' : undefined
|
|
77
|
+
const origin = observedCitation(originContribution, record.origin, kind)
|
|
78
|
+
if (record.family === 'hyperlink') {
|
|
79
|
+
const resolved = candidates.get(record.origin.repoId).edges.filter(edge => same(edge.sourceRef, origin.provenance))
|
|
80
|
+
need(record.predicate === 'links-to' && resolved.length === 1 &&
|
|
81
|
+
same(resolved[0].source, record.subject) && same(resolved[0].target, record.object))
|
|
82
|
+
}
|
|
83
|
+
if (record.family === 'source-declaration') need(same(origin.source, record.subject) && origin.predicate === record.predicate &&
|
|
84
|
+
same(resolveLegacyNodeRef(origin.target, [...contributions.values()]), record.object))
|
|
85
|
+
const evidence = new Map([...contributions.keys()].map(repoId => [repoId, []]))
|
|
86
|
+
for (const [index, occurrence] of occurrences.entries()) {
|
|
87
|
+
const contribution = contributions.get(occurrence.source.repoId)
|
|
88
|
+
const observed = index === 0 ? origin : observedCitation(contribution, occurrence.source)
|
|
89
|
+
evidence.get(contribution.repository.repoId).push({ evidenceId: occurrence.id, sourceRef: observed.provenance })
|
|
90
|
+
}
|
|
91
|
+
const edge = { edgeId, source: record.subject, target: record.object, predicate: record.predicate, family: record.family,
|
|
92
|
+
sourceRef: { repoId: record.home.repoId, path: home.path, revision: homeContribution.snapshot.digest, digest: home.rawDigest },
|
|
93
|
+
supportRefs: occurrences.map(value => value.id) }
|
|
94
|
+
return immutable([...candidates.values()].sort((a, b) => compare(a.repository.repoId, b.repository.repoId)).map(candidate =>
|
|
95
|
+
structuredClone({ ...candidate, edges: candidate.repository.repoId === record.home.repoId ? [edge] : [],
|
|
96
|
+
evidence: evidence.get(candidate.repository.repoId) })))
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof TypeError && [invalid, missing, oversized].includes(error.message)) throw error
|
|
99
|
+
throw new TypeError(invalid)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Aggregate supplied policy records after the host selects current recipient scope.
|
|
2
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
3
|
+
import { immutable } from '../edge-policy/contract.mjs'
|
|
4
|
+
import { adaptMultiContributionRecord } from './multi-contribution-record-adapter.mjs'
|
|
5
|
+
import { adaptRepositoryContribution } from './source-adapter.mjs'
|
|
6
|
+
|
|
7
|
+
const invalid = 'Invalid multi-record projection input'
|
|
8
|
+
const duplicate = 'Duplicate graph edge or evidence identity'
|
|
9
|
+
const oversized = 'Graph projection exceeds graph access bounds'
|
|
10
|
+
const missing = 'Required edge record repository is not selected'
|
|
11
|
+
const need = (value, message = invalid) => { if (!value) throw new TypeError(message) }
|
|
12
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
13
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
14
|
+
const sourceOnly = value => ({ ...value, edges: [], evidence: [] })
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return complete per-repository candidates for explicitly selected records and
|
|
18
|
+
* native contributions. The host owns grants, current permitted-record selection,
|
|
19
|
+
* source verification and policy acceptance; this function creates none of them.
|
|
20
|
+
* Empty records retain selected source nodes with no governed edges or evidence.
|
|
21
|
+
* Rebuild all candidates when grants change; never slice an older recipient bundle.
|
|
22
|
+
* Delegated source validation repeats per record (O(records × selected repos));
|
|
23
|
+
* input limits are refusal bounds, not a demonstrated large-batch throughput claim.
|
|
24
|
+
*/
|
|
25
|
+
export function adaptMultiRecordProjection(records, selected, options = {}) {
|
|
26
|
+
try {
|
|
27
|
+
need(plain(options) && Object.keys(options).every(name => name === 'topology'))
|
|
28
|
+
need(Array.isArray(records) && Array.isArray(selected))
|
|
29
|
+
// Refuse oversized work before reading or validating any selected source.
|
|
30
|
+
need(records.length <= 10000 && selected.length <= 64, oversized)
|
|
31
|
+
need(records.length === 0 || selected.length > 0, missing)
|
|
32
|
+
const candidates = new Map(), sourcePins = new Map()
|
|
33
|
+
for (const entry of selected) {
|
|
34
|
+
need(plain(entry) && Object.keys(entry).sort().join(',') === 'contribution,ownerRef')
|
|
35
|
+
const candidate = sourceOnly(adaptRepositoryContribution(entry.contribution, { ownerRef: entry.ownerRef }))
|
|
36
|
+
const repoId = candidate.repository.repoId
|
|
37
|
+
need(!candidates.has(repoId))
|
|
38
|
+
candidates.set(repoId, candidate)
|
|
39
|
+
sourcePins.set(repoId, canonicalize(candidate))
|
|
40
|
+
}
|
|
41
|
+
const edgeIds = new Set(), evidenceIds = new Set()
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
const bundle = adaptMultiContributionRecord(record, selected, options)
|
|
44
|
+
for (const contribution of bundle) {
|
|
45
|
+
const repoId = contribution.repository.repoId
|
|
46
|
+
need(sourcePins.get(repoId) === canonicalize(sourceOnly(contribution)))
|
|
47
|
+
const candidate = candidates.get(repoId)
|
|
48
|
+
for (const edge of contribution.edges) {
|
|
49
|
+
need(!edgeIds.has(edge.edgeId), duplicate)
|
|
50
|
+
edgeIds.add(edge.edgeId)
|
|
51
|
+
candidate.edges.push(edge)
|
|
52
|
+
}
|
|
53
|
+
for (const evidence of contribution.evidence) {
|
|
54
|
+
need(!evidenceIds.has(evidence.evidenceId), duplicate)
|
|
55
|
+
evidenceIds.add(evidence.evidenceId)
|
|
56
|
+
candidate.evidence.push(evidence)
|
|
57
|
+
}
|
|
58
|
+
need(candidate.edges.length <= 10000 && candidate.evidence.length <= 10000, oversized)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const complete = [...candidates.values()].sort((a, b) => compare(a.repository.repoId, b.repository.repoId)).map(candidate => ({
|
|
62
|
+
...candidate,
|
|
63
|
+
edges: candidate.edges.sort((a, b) => compare(a.edgeId, b.edgeId)),
|
|
64
|
+
evidence: candidate.evidence.sort((a, b) => compare(a.evidenceId, b.evidenceId)),
|
|
65
|
+
}))
|
|
66
|
+
for (const candidate of complete) need(candidate.nodes.length <= 10000 && candidate.snapshot.files.length <= 10000 &&
|
|
67
|
+
Buffer.byteLength(canonicalize(candidate)) <= 8 * 1024 * 1024, oversized)
|
|
68
|
+
return immutable(structuredClone(complete))
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof TypeError && error.message === 'Edge record exceeds graph access bounds') throw new TypeError(oversized)
|
|
71
|
+
if (error instanceof TypeError && [invalid, duplicate, oversized, missing].includes(error.message)) throw error
|
|
72
|
+
throw new TypeError(invalid)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Pure projection from the source producer; ownership and access remain host-owned.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
4
|
+
import { deepFreeze } from '../composition/wire.mjs'
|
|
5
|
+
import { validateRepositoryContribution } from '../graph/repo-contribution.mjs'
|
|
6
|
+
|
|
7
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
8
|
+
const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 512 && !/[\u0000-\u001f]/u.test(value)
|
|
9
|
+
const ref = value => plain(value) && Object.keys(value).sort().join(',') === 'localId,repoId' && text(value.repoId) && text(value.localId)
|
|
10
|
+
const key = value => canonicalize([value.repoId, value.localId])
|
|
11
|
+
const equal = (a, b) => canonicalize(a) === canonicalize(b)
|
|
12
|
+
const hash = value => createHash('sha256').update(canonicalize(value)).digest('hex')
|
|
13
|
+
const need = value => { if (!value) throw new TypeError('Invalid graph access source input') }
|
|
14
|
+
// Resolution may change endpoints, never the observed occurrence or its pin.
|
|
15
|
+
const occurrence = link => canonicalize({ ...link, target: null, resolution: 'unresolved' })
|
|
16
|
+
|
|
17
|
+
function resolvedLinks(contribution, topology) {
|
|
18
|
+
if (topology === undefined) return contribution.ordinaryLinks
|
|
19
|
+
need(plain(topology) && Array.isArray(topology.nodes) && Array.isArray(topology.ordinaryLinks))
|
|
20
|
+
const nodes = new Map()
|
|
21
|
+
for (const node of topology.nodes) {
|
|
22
|
+
need(ref(node?.ref) && !nodes.has(key(node.ref)))
|
|
23
|
+
nodes.set(key(node.ref), node)
|
|
24
|
+
}
|
|
25
|
+
for (const node of contribution.nodes) need(equal(nodes.get(key(node.ref)), node))
|
|
26
|
+
const links = new Map()
|
|
27
|
+
for (const link of topology.ordinaryLinks) {
|
|
28
|
+
if (link?.source?.repoId !== contribution.repository.repoId) continue
|
|
29
|
+
need(plain(link))
|
|
30
|
+
const id = occurrence(link)
|
|
31
|
+
need(!links.has(id))
|
|
32
|
+
need(link.target === null
|
|
33
|
+
? link.resolution === 'unresolved'
|
|
34
|
+
: ref(link.target) && nodes.has(key(link.target)) && link.resolution === (link.target.repoId === link.source.repoId ? 'resolved-local' : 'resolved-cross-repository'))
|
|
35
|
+
links.set(id, link)
|
|
36
|
+
}
|
|
37
|
+
need(links.size === contribution.ordinaryLinks.length)
|
|
38
|
+
return contribution.ordinaryLinks.map(link => {
|
|
39
|
+
const resolved = links.get(occurrence(link))
|
|
40
|
+
need(resolved)
|
|
41
|
+
return resolved
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize one buildRepositoryContribution result for createGraphAccess.
|
|
47
|
+
* ownerRef is an explicit trusted-host binding, never derived from source data.
|
|
48
|
+
* Optional topology comes from the host's explicitly supplied contribution set;
|
|
49
|
+
* it binds endpoints only and supplies no recipient grant or edge acceptance.
|
|
50
|
+
* Declared semantic relations stay with their assertion owner and are not
|
|
51
|
+
* converted into hyperlinks or activated by this navigation-only adapter.
|
|
52
|
+
* Structural integrity here does not verify raw bytes, ownership or freshness;
|
|
53
|
+
* the host's verifyContribution and evaluateEdge ports remain required.
|
|
54
|
+
*/
|
|
55
|
+
export function adaptRepositoryContribution(contribution, options = {}) {
|
|
56
|
+
need(plain(options) && Object.keys(options).every(k => ['ownerRef', 'topology'].includes(k)) && text(options.ownerRef))
|
|
57
|
+
need(validateRepositoryContribution(contribution).ok)
|
|
58
|
+
const links = resolvedLinks(contribution, options.topology).filter(link => link.target !== null)
|
|
59
|
+
const evidence = [], edges = []
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
for (const link of links) {
|
|
62
|
+
const occurrenceDigest = hash({ source: link.source, sourceRef: link.provenance })
|
|
63
|
+
need(!seen.has(occurrenceDigest)); seen.add(occurrenceDigest)
|
|
64
|
+
const evidenceId = `ordinary-link-evidence:${occurrenceDigest}`
|
|
65
|
+
evidence.push({ evidenceId, sourceRef: link.provenance })
|
|
66
|
+
edges.push({ edgeId: `ordinary-link:${hash({ source: link.source, target: link.target, sourceRef: link.provenance })}`,
|
|
67
|
+
source: link.source, target: link.target, predicate: 'links_to', family: 'hyperlink',
|
|
68
|
+
sourceRef: link.provenance, supportRefs: [evidenceId] })
|
|
69
|
+
}
|
|
70
|
+
return deepFreeze(structuredClone({
|
|
71
|
+
repository: { repoId: contribution.repository.repoId, ownerRef: options.ownerRef },
|
|
72
|
+
snapshot: contribution.snapshot,
|
|
73
|
+
inputs: contribution.inputs,
|
|
74
|
+
nodes: contribution.nodes.map(node => ({
|
|
75
|
+
ref: node.ref, sourceKind: node.kgType, title: node.title, summary: node.summary,
|
|
76
|
+
classification: node.classification, lifecycle: node.lifecycle, tags: node.tags,
|
|
77
|
+
sourceRef: node.source, displayEligible: true,
|
|
78
|
+
})),
|
|
79
|
+
edges, evidence,
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
2
|
+
|
|
3
|
+
const order = (left, right) => left < right ? -1 : left > right ? 1 : 0
|
|
4
|
+
const text = value => typeof value === 'string' && value.length > 0
|
|
5
|
+
const fail = () => { throw new TypeError('Snapshot comparison requires matching authorized graph views') }
|
|
6
|
+
const refKey = ref => ref && text(ref.repoId) && text(ref.localId) ? canonicalize([ref.repoId, ref.localId]) : fail()
|
|
7
|
+
const sourceKey = ref => ref && text(ref.repoId) && text(ref.path) ? canonicalize([ref.repoId, ref.path]) : fail()
|
|
8
|
+
|
|
9
|
+
function index(items, keyOf) {
|
|
10
|
+
if (!Array.isArray(items)) fail()
|
|
11
|
+
const records = new Map()
|
|
12
|
+
for (const item of items) {
|
|
13
|
+
const key = keyOf(item)
|
|
14
|
+
if (!text(key) || records.has(key)) fail()
|
|
15
|
+
records.set(key, item)
|
|
16
|
+
}
|
|
17
|
+
return records
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function changes(before, after, identity) {
|
|
21
|
+
const added = [], removed = [], changed = []
|
|
22
|
+
for (const key of [...new Set([...before.keys(), ...after.keys()])].sort(order)) {
|
|
23
|
+
if (!before.has(key)) added.push(identity(after.get(key)))
|
|
24
|
+
else if (!after.has(key)) removed.push(identity(before.get(key)))
|
|
25
|
+
else if (canonicalize(before.get(key)) !== canonicalize(after.get(key))) changed.push(identity(after.get(key)))
|
|
26
|
+
}
|
|
27
|
+
return { added, removed, changed }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function records(view) {
|
|
31
|
+
if (!view || view.schema !== 'atelier.graph-view@v1' || !text(view.projectionDigest) || !view.accessContext) fail()
|
|
32
|
+
const nodes = index(view.nodes, node => refKey(node.ref))
|
|
33
|
+
const edges = index(view.edges, edge => text(edge.edgeId) ? edge.edgeId : fail())
|
|
34
|
+
const evidence = index(view.evidence, item => text(item.evidenceId) ? item.evidenceId : fail())
|
|
35
|
+
const questions = index(view.questions, item => text(item.questionId) ? item.questionId : fail())
|
|
36
|
+
const sources = new Map()
|
|
37
|
+
function addSource(source) {
|
|
38
|
+
if (!source) return
|
|
39
|
+
const key = sourceKey(source)
|
|
40
|
+
// Keep all exact source pins (including locators), not whichever record
|
|
41
|
+
// happened to be visited last. One source can support several questions.
|
|
42
|
+
if (!sources.has(key)) sources.set(key, { repoId: source.repoId, path: source.path, pins: new Set() })
|
|
43
|
+
sources.get(key).pins.add(canonicalize(source))
|
|
44
|
+
}
|
|
45
|
+
for (const item of [...nodes.values(), ...evidence.values()]) addSource(item.sourceRef)
|
|
46
|
+
for (const repository of view.sourceSnapshot?.repositories ?? []) {
|
|
47
|
+
for (const file of repository.files ?? []) addSource({ repoId: repository.repoId, path: file.path, revision: repository.digest, digest: file.rawDigest, byteLength: file.byteLength })
|
|
48
|
+
for (const input of Object.values(repository.inputs ?? {})) addSource(input)
|
|
49
|
+
}
|
|
50
|
+
for (const [key, source] of sources) sources.set(key, { ...source, pins: [...source.pins].sort(order) })
|
|
51
|
+
for (const edge of edges.values()) {
|
|
52
|
+
if (!nodes.has(refKey(edge.source)) || !nodes.has(refKey(edge.target)) ||
|
|
53
|
+
!Array.isArray(edge.supportRefs) || edge.supportRefs.some(id => !evidence.has(id))) fail()
|
|
54
|
+
}
|
|
55
|
+
for (const question of questions.values()) {
|
|
56
|
+
if (!Array.isArray(question.nodeRefs) || question.nodeRefs.some(ref => !nodes.has(refKey(ref))) ||
|
|
57
|
+
!Array.isArray(question.evidenceRefs) || question.evidenceRefs.some(id => !evidence.has(id)) ||
|
|
58
|
+
(question.contradictingRefs !== undefined && (!Array.isArray(question.contradictingRefs) || question.contradictingRefs.some(id => !evidence.has(id))))) fail()
|
|
59
|
+
}
|
|
60
|
+
return { nodes, edges, evidence, questions, sources }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Derived data only. The trusted caller must revalidate BOTH snapshots before
|
|
64
|
+
// calling. Matching accessContext refuses changed grants/principals/filters;
|
|
65
|
+
// it is a compatibility check, never proof of recipient authorization.
|
|
66
|
+
export function compareGraphSnapshots(before, after) {
|
|
67
|
+
if (!before?.accessContext || canonicalize(before.accessContext) !== canonicalize(after?.accessContext)) fail()
|
|
68
|
+
const a = records(before), b = records(after)
|
|
69
|
+
const nodes = changes(a.nodes, b.nodes, node => node.ref)
|
|
70
|
+
const edges = changes(a.edges, b.edges, edge => edge.edgeId)
|
|
71
|
+
const sources = changes(a.sources, b.sources, source => ({ repoId: source.repoId, path: source.path }))
|
|
72
|
+
const evidenceChanges = changes(a.evidence, b.evidence, item => item.evidenceId)
|
|
73
|
+
const questionChanges = changes(a.questions, b.questions, item => item.questionId)
|
|
74
|
+
const affectedNodes = new Set([...nodes.added, ...nodes.removed, ...nodes.changed].map(refKey))
|
|
75
|
+
const affectedEdges = new Set([...edges.added, ...edges.removed, ...edges.changed])
|
|
76
|
+
const affectedSources = new Set([...sources.added, ...sources.removed, ...sources.changed].map(sourceKey))
|
|
77
|
+
const affectedEvidence = new Set([...evidenceChanges.added, ...evidenceChanges.removed, ...evidenceChanges.changed])
|
|
78
|
+
const affectedQuestions = new Set([...questionChanges.added, ...questionChanges.removed, ...questionChanges.changed])
|
|
79
|
+
const adjacency = new Map()
|
|
80
|
+
for (const graph of [a, b]) {
|
|
81
|
+
for (const node of graph.nodes.values()) if (node.sourceRef && affectedSources.has(sourceKey(node.sourceRef))) affectedNodes.add(refKey(node.ref))
|
|
82
|
+
for (const item of graph.evidence.values()) if (item.sourceRef && affectedSources.has(sourceKey(item.sourceRef))) affectedEvidence.add(item.evidenceId)
|
|
83
|
+
for (const edge of graph.edges.values()) {
|
|
84
|
+
const from = refKey(edge.source), to = refKey(edge.target)
|
|
85
|
+
if (!adjacency.has(from)) adjacency.set(from, new Set())
|
|
86
|
+
if (!adjacency.has(to)) adjacency.set(to, new Set())
|
|
87
|
+
adjacency.get(from).add(to)
|
|
88
|
+
adjacency.get(to).add(from)
|
|
89
|
+
if (affectedEdges.has(edge.edgeId) || edge.supportRefs.some(id => affectedEvidence.has(id))) {
|
|
90
|
+
affectedNodes.add(from); affectedNodes.add(to)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Conservative possible-impact reachability, not a claim that every link is
|
|
95
|
+
// a semantic dependency. Coverage is limited to the supplied authorized slice.
|
|
96
|
+
const queue = [...affectedNodes]
|
|
97
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
98
|
+
for (const neighbor of adjacency.get(queue[cursor]) ?? []) if (!affectedNodes.has(neighbor)) {
|
|
99
|
+
affectedNodes.add(neighbor); queue.push(neighbor)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const graph of [a, b]) for (const question of graph.questions.values()) {
|
|
103
|
+
if (question.nodeRefs.some(ref => affectedNodes.has(refKey(ref))) || [...question.evidenceRefs, ...(question.contradictingRefs ?? [])].some(id => affectedEvidence.has(id))) affectedQuestions.add(question.questionId)
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
beforeDigest: before.projectionDigest, afterDigest: after.projectionDigest,
|
|
107
|
+
nodes, edges, sources,
|
|
108
|
+
possibleImpact: { scope: 'supplied-authorized-slice', questionIds: [...affectedQuestions].sort(order) },
|
|
109
|
+
}
|
|
110
|
+
}
|