@mnstry/atelier 0.2.0-alpha.14 → 0.2.0-alpha.16

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 (236) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +80 -47
  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/claims.md +2 -2
  10. package/docs/blocks/conformance-admission.md +1 -1
  11. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  12. package/docs/guides/co-authoring.md +113 -0
  13. package/docs/{distributions.md → guides/distributions.md} +13 -8
  14. package/docs/guides/extensions.md +189 -0
  15. package/docs/guides/graph-refresh.md +152 -0
  16. package/docs/guides/graph-workspace.md +76 -0
  17. package/docs/{ingestion.md → guides/ingestion.md} +13 -12
  18. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  19. package/docs/{learning.md → guides/learning.md} +12 -5
  20. package/docs/{obsidian.md → guides/obsidian.md} +134 -47
  21. package/docs/guides/ontology-guide.md +111 -0
  22. package/docs/{personal-workspace.md → guides/personal-workspace.md} +7 -4
  23. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  24. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  25. package/docs/{upgrade.md → guides/upgrade.md} +40 -9
  26. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  27. package/docs/knowledge-health/facilitator.md +15 -4
  28. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  29. package/docs/knowledge-health/quickstart.md +97 -75
  30. package/docs/knowledge-health/readme.md +13 -5
  31. package/docs/maintainers/describing-the-atelier.md +173 -0
  32. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -5
  33. package/docs/{announcements.md → reference/announcements.md} +1 -1
  34. package/docs/{architecture.md → reference/architecture.md} +31 -5
  35. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  36. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  37. package/docs/{coordination.md → reference/coordination.md} +1 -1
  38. package/docs/{decisions.md → reference/decisions.md} +3 -3
  39. package/docs/{design.md → reference/design.md} +1 -1
  40. package/docs/reference/edge-policy.md +151 -0
  41. package/docs/reference/graph-view-structured.md +119 -0
  42. package/docs/reference/graph-view.md +139 -0
  43. package/docs/reference/graph-workspace.md +37 -0
  44. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  45. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  46. package/docs/{local-services.md → reference/local-services.md} +1 -1
  47. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +6 -4
  48. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +5 -66
  49. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  50. package/docs/reference/portability.md +92 -0
  51. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  52. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  53. package/docs/{project-options.md → reference/project-options.md} +17 -0
  54. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +27 -4
  55. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  56. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  57. package/docs/{trackables.md → reference/trackables.md} +2 -2
  58. package/docs/start/independent-start.md +89 -0
  59. package/docs/{install.md → start/install.md} +6 -6
  60. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  61. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  62. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  63. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  64. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  65. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  66. package/fixtures/knowledge-health/workshop/README.md +1 -1
  67. package/fixtures/templates/local-library.v1.json +3 -1
  68. package/package.json +21 -8
  69. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  70. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  71. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  72. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  73. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  74. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  75. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  76. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  77. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  78. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  79. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  80. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  81. package/src/architecture/catalog.mjs +2 -2
  82. package/src/attestation/sign.mjs +1 -1
  83. package/src/boundary/content-rules.mjs +5 -4
  84. package/src/boundary/policy.mjs +60 -12
  85. package/src/cli/run.mjs +24 -8
  86. package/src/commands/distribution.mjs +1 -1
  87. package/src/commands/extension.mjs +27 -0
  88. package/src/commands/ingest.mjs +1 -1
  89. package/src/commands/learn.mjs +1 -1
  90. package/src/commands/obsidian.mjs +28 -16
  91. package/src/commands/review.mjs +3 -0
  92. package/src/edge-policy/contract.mjs +69 -0
  93. package/src/edge-policy/policy.mjs +142 -0
  94. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  95. package/src/extension-packs/loader.mjs +4 -3
  96. package/src/extensions/admission.mjs +83 -0
  97. package/src/extensions/client.d.mts +23 -0
  98. package/src/extensions/client.mjs +99 -0
  99. package/src/extensions/contracts.d.mts +29 -0
  100. package/src/extensions/contracts.mjs +163 -0
  101. package/src/extensions/convex.d.mts +24 -0
  102. package/src/extensions/convex.mjs +189 -0
  103. package/src/extensions/delegation.mjs +54 -0
  104. package/src/extensions/drizzle.d.mts +11 -0
  105. package/src/extensions/drizzle.mjs +33 -0
  106. package/src/extensions/postgres.d.mts +7 -0
  107. package/src/extensions/postgres.mjs +66 -0
  108. package/src/extensions/prisma.d.mts +13 -0
  109. package/src/extensions/prisma.mjs +63 -0
  110. package/src/extensions/project.mjs +432 -0
  111. package/src/extensions/scaffold-convex.mjs +130 -0
  112. package/src/extensions/scaffold-sql.mjs +144 -0
  113. package/src/extensions/server.d.mts +20 -0
  114. package/src/extensions/server.mjs +102 -0
  115. package/src/extensions/sql-scope.mjs +22 -0
  116. package/src/graph/knowledge-graph.mjs +1 -1
  117. package/src/graph/repo-contribution.mjs +405 -0
  118. package/src/graph/repo-contribution.types.mjs +32 -0
  119. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  120. package/src/graph-access/edge-record-adapter.mjs +73 -0
  121. package/src/graph-access/index.mjs +253 -0
  122. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  123. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  124. package/src/graph-access/source-adapter.mjs +81 -0
  125. package/src/graph-refresh/compare.mjs +110 -0
  126. package/src/graph-refresh/controller.mjs +324 -0
  127. package/src/graph-refresh/index.mjs +3 -0
  128. package/src/graph-refresh/local.mjs +78 -0
  129. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  130. package/src/graph-view/babylon/index.mjs +2 -0
  131. package/src/graph-view/babylon/navigator.mjs +312 -0
  132. package/src/graph-view/babylon/provenance.mjs +36 -0
  133. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  134. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  135. package/src/graph-view/babylon/web-host.mjs +188 -0
  136. package/src/graph-view/cli.mjs +3 -0
  137. package/src/graph-view/command.mjs +34 -0
  138. package/src/graph-view/contracts.mjs +79 -0
  139. package/src/graph-view/errors.mjs +5 -0
  140. package/src/graph-view/index.mjs +4 -0
  141. package/src/graph-view/intents.mjs +30 -0
  142. package/src/graph-view/query.mjs +213 -0
  143. package/src/graph-view/refs.mjs +2 -0
  144. package/src/graph-view/structured/browser.mjs +262 -0
  145. package/src/graph-view/structured/styles.mjs +8 -0
  146. package/src/graph-workspace/authoring.mjs +185 -0
  147. package/src/graph-workspace/browser/dom.mjs +23 -0
  148. package/src/graph-workspace/browser/index.mjs +6 -0
  149. package/src/graph-workspace/browser/map.mjs +374 -0
  150. package/src/graph-workspace/browser/review.mjs +55 -0
  151. package/src/graph-workspace/browser/styles.mjs +11 -0
  152. package/src/graph-workspace/browser/workbench.mjs +351 -0
  153. package/src/graph-workspace/browser/workspace.mjs +433 -0
  154. package/src/graph-workspace/disclosure.mjs +39 -0
  155. package/src/graph-workspace/geometry.mjs +78 -0
  156. package/src/graph-workspace/index.mjs +11 -0
  157. package/src/graph-workspace/labels.mjs +50 -0
  158. package/src/graph-workspace/model.mjs +109 -0
  159. package/src/graph-workspace/recipes.mjs +100 -0
  160. package/src/graph-workspace/scale.mjs +98 -0
  161. package/src/graph-workspace/theme.mjs +156 -0
  162. package/src/knowledge/ontology-guide.mjs +152 -0
  163. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  164. package/src/knowledge-health/rule-identity.mjs +7 -7
  165. package/src/knowledge-health/workshop.mjs +22 -2
  166. package/src/personal-workspace/selection.mjs +17 -2
  167. package/src/project/config.mjs +1 -1
  168. package/src/project/providers/github.mjs +24 -0
  169. package/src/project/repo-identity.mjs +3 -22
  170. package/src/projection/obsidian/contracts.mjs +1 -1
  171. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  172. package/src/projection/obsidian/materialize/prepare-view.mjs +1 -1
  173. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  174. package/src/projection/obsidian/personal-workspace.mjs +24 -28
  175. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  176. package/src/projection/obsidian/publication/publisher.mjs +25 -4
  177. package/src/projection/project.mjs +18 -14
  178. package/src/readiness/readiness.mjs +20 -11
  179. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  180. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  181. package/src/readiness-protocols/evidence.mjs +2 -1
  182. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  183. package/src/readiness-protocols/runtime.mjs +43 -6
  184. package/src/runtime/obsidian/engine.mjs +99 -26
  185. package/src/runtime/obsidian/lifecycle.mjs +3 -3
  186. package/src/runtime/obsidian/login-item.mjs +2 -2
  187. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  188. package/src/runtime/obsidian/opening.mjs +2 -2
  189. package/src/runtime/obsidian/personal-selection.mjs +88 -26
  190. package/src/runtime/obsidian/pipeline.mjs +75 -7
  191. package/src/runtime/obsidian/private-lock.mjs +11 -2
  192. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  193. package/src/runtime/obsidian/service-main.mjs +15 -9
  194. package/src/runtime/obsidian/service.mjs +14 -0
  195. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  196. package/src/runtime/obsidian/view-permission.mjs +2 -1
  197. package/src/upgrade/upgrade.mjs +23 -4
  198. package/templates/distribution-workspace/README.md +2 -2
  199. package/templates/external-project-workspace/atelier.project.json +3 -0
  200. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  201. package/templates/knowledge-workspace/README.md +2 -2
  202. package/docs/integration-and-review-plan.md +0 -409
  203. package/docs/integration-contract-decisions.md +0 -44
  204. package/docs/knowledge-health/devday/facilitator.md +0 -16
  205. package/docs/knowledge-health/devday/public-default.md +0 -107
  206. package/docs/knowledge-health/devday/quickstart.md +0 -18
  207. package/docs/knowledge-health/devday/support.md +0 -22
  208. package/docs/knowledge-health/exercise.md +0 -17
  209. package/docs/knowledge-health/support.md +0 -34
  210. package/docs/presentation-review-disposition.md +0 -47
  211. package/docs/progressive-ingestion-acceptance.md +0 -89
  212. package/docs/progressive-ingestion.md +0 -235
  213. /package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +0 -0
  214. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  215. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  216. /package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +0 -0
  217. /package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +0 -0
  218. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  219. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  220. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  221. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  222. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  223. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  224. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  225. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  226. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  227. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  228. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  229. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  230. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  231. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  232. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  233. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  234. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  235. /package/docs/{semantic-operation.md → reference/semantic-operation.md} +0 -0
  236. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -9,7 +9,7 @@ ignored .atelier-local/ state. Plans bind explicit sources, scope and budgets.
9
9
  Run processes bounded local text, CSV and JSON. Status retains every selected
10
10
  source's disposition. Query returns source-bound lexical evidence with coverage;
11
11
  no operation accepts semantic claims, calls a provider or enrolls raw files into
12
- the graph. Output is private JSON. See docs/ingestion.md.`
12
+ the graph. Output is private JSON. See docs/guides/ingestion.md.`
13
13
 
14
14
  try {
15
15
  const command = process.argv[2]
@@ -17,7 +17,7 @@ lesson digest. Activate makes approved content available to a named harness;
17
17
  it does not install files or grant tool permissions. Withdraw stops future use.
18
18
  List, context, render, graph, export and plan print private JSON to stdout.
19
19
  Export is an explicit private archive, never a send or publication operation.
20
- See docs/learning.md for complete examples.`
20
+ See docs/guides/learning.md for complete examples.`
21
21
 
22
22
  function exactObject(value, allowed, required) {
23
23
  if (!value || typeof value !== 'object' || Array.isArray(value)
@@ -17,7 +17,7 @@ import { BUILT_IN_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createObsidianRegistr
17
17
  import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, requestServiceTick, runtimeRelease, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
18
18
  import {
19
19
  DECISIONS, ONLY_YOU_AUDIENCES, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, installApplyPolicy, localPointerPath, protectedRoots,
20
- readInstalledApplyPolicy, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
20
+ readInstalledApplyPolicy, readLocalPointer, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
21
21
  } from '../runtime/obsidian/machine-settings.mjs'
22
22
  import {
23
23
  currentLoginItemPlan, installLoginItem, loginItemDataRoot, loginItemLabel, loginItemStarter, loginItemStatus, planLoginItem, projectNameOf, readLoginItemRecord, realNodePath,
@@ -29,7 +29,7 @@ import { pluginPresenceOf, turnPluginOnNext, withPluginFilesPending, withPluginR
29
29
  import { planViewAdd, viewFromRequest, writeViewPlan } from '../runtime/obsidian/project-views.mjs'
30
30
  import { askGoAhead, createQuestioner } from '../runtime/obsidian/questions.mjs'
31
31
  import { readServiceSettings } from '../runtime/obsidian/service-record.mjs'
32
- import { resolveServiceWorkspace } from '../runtime/obsidian/service.mjs'
32
+ import { resolveServiceWorkspace, serviceLocationCheck } from '../runtime/obsidian/service.mjs'
33
33
  import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from '../runtime/obsidian/startup-adapters.mjs'
34
34
  import { OBSIDIAN_SETTINGS_FILE, obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../projection/obsidian/publication/vault-list.mjs'
35
35
  import { checkVaultParent, projectDisplayName, vaultFolderName } from '../runtime/obsidian/vault-location.mjs'
@@ -115,7 +115,7 @@ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.pro
115
115
  workspace (the personal-workspace module's selection); its views come from the overlay. Nothing here confirms one: while
116
116
  none is confirmed and current nothing is published, and status, open and service start refuse with the reason. service
117
117
  status and stop, unit --remove and uninstall find the service from the home alone. Who may see its vaults stays this
118
- machine's audience setting, none by default.
118
+ machine's audience setting: owner only when undecided; an explicit clear stays empty.
119
119
  Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
120
120
  Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
121
121
  list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
@@ -383,7 +383,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
383
383
  const personalHome = flags['personal-home'] === undefined ? null : path.resolve(cwd, flags['personal-home'])
384
384
  if (personalHome !== null && configFlag !== undefined) refuse('usage', '--personal-home is given instead of --project, not with it')
385
385
  const selection = personalHome === null ? null : await import('../runtime/obsidian/personal-selection.mjs')
386
- const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
386
+ const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome, dataRoot, env, platform }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
387
387
  const locateProject = selection === null ? null : () => selection.locatePersonalHome({ personalHome })
388
388
  const registry = createObsidianRegistry({ reservedOperations: BUILT_IN_OPERATIONS, contributions: contributions ?? await loadContributions(contributionsDirectory === undefined ? {} : { directory: contributionsDirectory }) })
389
389
  const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
@@ -470,8 +470,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
470
470
  const pluginOf = async (scopeId) => pluginPresenceOf((await readServiceStatusDocument(lifecycle, lifecycleRules)).document, scopeId)
471
471
  const pluginLine = (plugin) => `${plugin.present ? `; plugin present (Obsidian ${plugin.appVersion})` : plugin.reason === 'turned-off-in-this-vault' ? '; plugin turned off in this vault' : `; plugin not present (${plugin.reason})`}${plugin.files === 'waits-for-app' ? '; plugin files wait for the app' : ''}`
472
472
 
473
- // A project bound to a personal workspace takes its views from the overlay, and has no place for these decisions
474
- // yet: the operations that would write them refuse before anything is resolved or written.
473
+ // A personal binding takes its views from the overlay and keeps its audience owner only (or explicitly empty).
474
+ // Operations outside those machine decisions refuse before anything is resolved or written.
475
475
  const refuseOnPersonalBinding = async (project, operation) => (await import('../projection/obsidian/personal-workspace.mjs')).refuseOnPersonalBinding(project, operation)
476
476
  const configured = () => {
477
477
  const project = loadProject()
@@ -482,7 +482,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
482
482
  // Private state of this workspace, created on demand for an operation that writes it.
483
483
  const writable = () => {
484
484
  const { project, enablement } = configured()
485
- ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }) })
485
+ ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
486
486
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create: true })
487
487
  return { project, enablement, workspace, repositoryRoots: protectedRoots(project), now: isoTime(clock) }
488
488
  }
@@ -632,7 +632,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
632
632
  const removed = await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager, clock })
633
633
  const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
634
634
  if (failed) return { exit: EXIT.notSuccess, document: { loginItem: removed }, human: [`login item not removed: ${removed.reason}${removed.message ? ` (${removed.message})` : ''}`, `Next: ${NEXT[removed.reason] ?? 'see `atelier obsidian status`'}`] }
635
- const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
635
+ const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off', { locate: true })
636
636
  let service = null
637
637
  if (removed.removed === true) {
638
638
  let entry = null
@@ -657,9 +657,18 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
657
657
  // The person's answer to "start at login?", remembered for this workspace as the `loginItem` decision of its machine
658
658
  // settings, given on the command line. By whom: the actor named, else a person at a terminal by the account's name,
659
659
  // else nobody known. Answers whether it wrote; an answer already remembered is not written again.
660
- const rememberLoginItem = (choice) => {
661
- if (readable().enablement.reason === 'not-configured') return false
662
- const { workspace, repositoryRoots, now } = writable()
660
+ const rememberLoginItem = (choice, { locate = false } = {}) => {
661
+ let workspace, repositoryRoots, now
662
+ if (locate && locateProject !== null) {
663
+ const project = locateProject()
664
+ workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
665
+ if (!workspace?.workspaceRoot) return false
666
+ repositoryRoots = protectedRoots(project)
667
+ now = isoTime(clock)
668
+ } else {
669
+ if (readable().enablement.reason === 'not-configured') return false
670
+ ;({ workspace, repositoryRoots, now } = writable())
671
+ }
663
672
  const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
664
673
  if (current.decisions.loginItem?.choice === choice) return false
665
674
  const decidedBy = flags['consent-actor'] ?? (interactive ? accountActor(account()) : null)
@@ -693,7 +702,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
693
702
  }
694
703
  const vaults = [...new Set([...underDataRoot, ...allocated])].sort()
695
704
  const userDataDir = obsidianUserDataDir({ platform, env })
696
- return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: project.configPath ? localPointerPath(project) : null, obsidianList: userDataDir === null ? null : path.join(userDataDir, OBSIDIAN_SETTINGS_FILE) }
705
+ return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: typeof project.configDir === 'string' && readLocalPointer(project) !== null ? localPointerPath(project) : null, obsidianList: userDataDir === null ? null : path.join(userDataDir, OBSIDIAN_SETTINGS_FILE) }
697
706
  }
698
707
 
699
708
  // Where a view's vault is, or will be: { path, origin }. A view the next tick will place is shown where it would go,
@@ -901,7 +910,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
901
910
  // `me` stands for "only you". Named beside other audiences, it adds its own to theirs, and the list is the person's own choice.
902
911
  const audienceAllow = [...new Set(named.flatMap((item) => (item === 'me' ? ONLY_YOU_AUDIENCES : [item])))]
903
912
  const choice = named.length === 1 && named[0] === 'me' ? 'only-you' : 'custom'
904
- await refuseOnPersonalBinding(loadProject(), `audience ${sub}`)
913
+ await refuseOnPersonalBinding(loadProject(), choice === 'only-you' ? 'audience set me' : `audience ${sub}`)
905
914
  const { workspace, repositoryRoots, now } = writable()
906
915
  const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
907
916
  // A vault that is only yours shows the notes that carry no classification too; any other list withholds them.
@@ -1090,8 +1099,10 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
1090
1099
  // again, then the proven service stops. Everything a person made or may want back stays, and is named.
1091
1100
  async uninstall() {
1092
1101
  const project = (locateProject ?? loadProject)()
1093
- let workspace = null
1094
- try { const found = resolveServiceWorkspace({ project, dataRoot, env, platform }); workspace = found?.workspaceRoot ? found : null } catch (error) { if (!isTyped(error)) throw error }
1102
+ // Only an established absence (no pointer, or no private state yet) is unprepared. A location or identity refusal
1103
+ // stops here, before any login-item or service action, so uninstall never reports success over it.
1104
+ const found = resolveServiceWorkspace({ project, dataRoot, env, platform })
1105
+ const workspace = found?.workspaceRoot ? found : null
1095
1106
  const record = workspace === null ? null : readLoginItemRecord(workspace)
1096
1107
  // What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
1097
1108
  const kept = keptLocations(project, workspace)
@@ -1099,7 +1110,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
1099
1110
  const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
1100
1111
  const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
1101
1112
  const serviceGone = service.stopped === true || service.state === 'stopped'
1102
- const remembered = workspace !== null && itemGone ? rememberLoginItem('off') : false
1113
+ const remembered = workspace !== null && itemGone ? rememberLoginItem('off', { locate: true }) : false
1103
1114
  return {
1104
1115
  exit: itemGone && serviceGone ? EXIT.ok : EXIT.notSuccess,
1105
1116
  document: { loginItem: item, service, kept, rememberedNow: { loginItem: remembered ? 'off' : null } },
@@ -1111,6 +1122,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
1111
1122
  ...kept.vaults.map((vault) => ` vault ${vault}`),
1112
1123
  ...(kept.privateState === null ? [] : [` private state ${kept.privateState}`]),
1113
1124
  ` project file ${kept.projectFile ?? '(none)'}; its Obsidian settings stay`,
1125
+ ...(kept.pointer === null ? [] : [` workspace pointer ${kept.pointer}`]),
1114
1126
  ...(kept.obsidianList === null ? [] : [` Obsidian's list ${kept.obsidianList}; the vaults stay in it`]),
1115
1127
  ],
1116
1128
  }
@@ -7,6 +7,7 @@ import {
7
7
  createProtocolRegistry,
8
8
  } from '../extension-packs/loader.mjs'
9
9
  import { runProtocol } from '../readiness-protocols/runtime.mjs'
10
+ import { bundledReadinessEnabled, requireSelectedReadinessProtocol } from '../readiness-protocols/bundled-selection.mjs'
10
11
  import { recordReviewEvidence } from '../readiness-protocols/evidence.mjs'
11
12
  import { createReviewStore } from '../collaboration/review-store.mjs'
12
13
  import {
@@ -73,7 +74,9 @@ let result
73
74
  if (args._[0] === 'run') {
74
75
  const registry = createProtocolRegistry({
75
76
  packs: loadExtensionPacks(project).packs,
77
+ includeBundled: bundledReadinessEnabled(project),
76
78
  })
79
+ requireSelectedReadinessProtocol(project, registry, args._[1])
77
80
  const answers = args.answers ? readJson(path.resolve(args.answers)) : {}
78
81
  const { run } = runProtocol(project, args._[1], {
79
82
  answers,
@@ -0,0 +1,69 @@
1
+ import crypto from 'node:crypto'
2
+ import { canonicalize } from '../attestation/jcs.mjs'
3
+
4
+ // Experimental adapter shapes, not an ontology or identity authority.
5
+ export const EDGE_RECORD_SCHEMA = 'atelier-edge-record@experimental-v1'
6
+ export const EDGE_DECISION_SCHEMA = 'atelier-edge-decision@experimental-v1'
7
+ export const EDGE_FAMILIES = Object.freeze(['hyperlink', 'source-declaration', 'reviewed-assertion'])
8
+ export const EDGE_ACTIONS = Object.freeze(['propose', 'approve', 'accept', 'reject', 'suspend', 'withdraw', 'supersede'])
9
+ const digestPattern = /^sha256:[a-f0-9]{64}$/
10
+ const text = (v, max = 256) => typeof v === 'string' && v.length > 0 && v.trim() === v && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v)
11
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
12
+ const closed = (v, keys) => object(v) && Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k))
13
+ const relativePath = (v) => text(v, 2048) && !v.includes('\\') && !v.startsWith('/') && !/^[A-Za-z]:/.test(v) && v.split('/').every((p) => p && p !== '.' && p !== '..')
14
+ export const validNodeRef = (v) => closed(v, ['repoId', 'localId']) && text(v.repoId) && text(v.localId)
15
+ export const bytesDigest = (v) => `sha256:${crypto.createHash('sha256').update(v).digest('hex')}`
16
+ export const edgeDigest = (v) => bytesDigest(canonicalize(v))
17
+ export function immutable(value) {
18
+ if (value && typeof value === 'object') {
19
+ for (const child of Object.values(value)) immutable(child)
20
+ Object.freeze(value)
21
+ }
22
+ return value
23
+ }
24
+
25
+ export function validSourceRef(v) {
26
+ return closed(v, ['repoId', 'path', 'revision', 'fileDigest', 'locator', 'citedDigest']) &&
27
+ text(v.repoId) && relativePath(v.path) && text(v.revision) && digestPattern.test(v.fileDigest) && digestPattern.test(v.citedDigest) &&
28
+ closed(v.locator, ['byteStart', 'byteEnd']) && Number.isSafeInteger(v.locator.byteStart) && v.locator.byteStart >= 0 &&
29
+ Number.isSafeInteger(v.locator.byteEnd) && v.locator.byteEnd > v.locator.byteStart
30
+ }
31
+
32
+ export function validateEdgeRecord(v) {
33
+ try {
34
+ if (!closed(v, ['schema', 'family', 'subject', 'predicate', 'object', 'home', 'origin', 'supports', 'policyDigest', 'proposalRef', 'sourceChange', 'qualifications'])) return false
35
+ if (v.schema !== EDGE_RECORD_SCHEMA || !EDGE_FAMILIES.includes(v.family) || !validNodeRef(v.subject) || !validNodeRef(v.object) || !text(v.predicate)) return false
36
+ if (!closed(v.home, ['repoId', 'path', 'readership']) || !text(v.home.repoId) || !relativePath(v.home.path) || !text(v.home.readership)) return false
37
+ if (!validSourceRef(v.origin) || !Array.isArray(v.supports) || v.supports.length > 256 ||
38
+ v.supports.some((s) => !closed(s, ['id', 'source']) || !text(s.id) || !validSourceRef(s.source)) || new Set(v.supports.map((s) => s.id)).size !== v.supports.length) return false
39
+ if (!digestPattern.test(v.policyDigest) || (v.proposalRef !== null && !text(v.proposalRef, 2048)) ||
40
+ !Array.isArray(v.qualifications) || v.qualifications.length > 64 || v.qualifications.some((q) => !text(q, 2048))) return false
41
+ if (v.family === 'hyperlink' && (v.predicate !== 'links-to' || v.sourceChange !== null)) return false
42
+ if (v.sourceChange !== null && (!closed(v.sourceChange, ['repoId', 'path', 'baseRevision', 'diffDigest', 'reviewRef']) ||
43
+ !text(v.sourceChange.repoId) || !relativePath(v.sourceChange.path) || !text(v.sourceChange.baseRevision) || !digestPattern.test(v.sourceChange.diffDigest) || !text(v.sourceChange.reviewRef, 2048))) return false
44
+ edgeDigest(v)
45
+ return true
46
+ } catch { return false }
47
+ }
48
+
49
+ export function validateEdgeDecision(v) {
50
+ try {
51
+ if (!closed(v, ['schema', 'recordDigest', 'sequence', 'previousDigest', 'action', 'actorId', 'at', 'policyDigest', 'reason', 'roles', 'signature'])) return false
52
+ if (v.schema !== EDGE_DECISION_SCHEMA || !digestPattern.test(v.recordDigest) || !Number.isSafeInteger(v.sequence) || v.sequence < 1 ||
53
+ (v.previousDigest !== null && !digestPattern.test(v.previousDigest)) || !EDGE_ACTIONS.includes(v.action) || !text(v.actorId) ||
54
+ !text(v.at) || !Number.isFinite(Date.parse(v.at)) || new Date(v.at).toISOString() !== v.at || !digestPattern.test(v.policyDigest) || !text(v.reason, 2048)) return false
55
+ if (!Array.isArray(v.roles) || v.roles.length > 3 || v.roles.some((r) => !closed(r, ['repoId', 'role']) || !text(r.repoId) || !['home-owner', 'endpoint-steward'].includes(r.role)) ||
56
+ new Set(v.roles.map((r) => canonicalize(r))).size !== v.roles.length) return false
57
+ if (v.action !== 'approve' && v.roles.length !== 0) return false
58
+ if (v.action === 'approve' && v.roles.length === 0) return false
59
+ if (!closed(v.signature, ['algorithm', 'keyId', 'value']) || !['ed25519', 'es256'].includes(v.signature.algorithm) || !text(v.signature.keyId) || !text(v.signature.value, 512)) return false
60
+ edgeDigest(v)
61
+ return true
62
+ } catch { return false }
63
+ }
64
+
65
+ export function edgeIdentity(record) {
66
+ if (!validateEdgeRecord(record)) throw new TypeError('invalid edge record')
67
+ const recordDigest = edgeDigest(record)
68
+ return Object.freeze({ edgeId: `edge:${recordDigest.slice(7)}`, recordDigest })
69
+ }
@@ -0,0 +1,142 @@
1
+ import { verifyDocument } from '../attestation/sign.mjs'
2
+ import { bytesDigest, edgeDigest, edgeIdentity, immutable, validateEdgeDecision, validateEdgeRecord } from './contract.mjs'
3
+
4
+ const roleKey = (role) => `${role.role}\0${role.repoId}`
5
+ const same = (a, b) => edgeDigest(a) === edgeDigest(b)
6
+ const fail = (state, reason) => ({ state, eligible: false, reason })
7
+ const decisions = new WeakSet()
8
+ const seal = (value) => { const result = immutable(value); decisions.add(result); return result }
9
+ function demand(value, reason) { if (!value) throw new Error(reason) }
10
+
11
+ function validPolicy(p) {
12
+ return p && Object.keys(p).sort().join(',') === 'allowProposerReview,allowSharedSteward,version' &&
13
+ typeof p.version === 'string' && p.version.length > 0 && typeof p.allowProposerReview === 'boolean' && typeof p.allowSharedSteward === 'boolean'
14
+ }
15
+
16
+ // Content-bound occurrence identity only; this does not establish access,
17
+ // source currency or eligibility. Keep caller-owned records untouched.
18
+ export function edgeEvidenceOccurrences(record) {
19
+ const { edgeId } = edgeIdentity(record)
20
+ const occurrence = (id, source) => ({ id: `evidence:${edgeDigest({ edgeId, occurrenceId: id, source }).slice(7)}`, occurrenceId: id, source: structuredClone(source) })
21
+ return immutable([occurrence('origin', record.origin), ...record.supports.map((s) => occurrence(`support:${s.id}`, s.source))])
22
+ }
23
+
24
+ // Host functions are trusted integration ports. No record-supplied identity,
25
+ // eligible flag, audience or local asserted review substitutes for these ports.
26
+ // The host must hold one coherent current snapshot across an evaluation.
27
+ export async function evaluateEdge(input, host) {
28
+ let record, events
29
+ try {
30
+ record = structuredClone(input.record)
31
+ events = structuredClone(input.events)
32
+ if (!validateEdgeRecord(record) || !Array.isArray(events) || events.length > 1024 || events.some((e) => !validateEdgeDecision(e))) return immutable(fail('suspended', 'invalid-input'))
33
+ immutable(record); immutable(events)
34
+ const identity = edgeIdentity(record)
35
+ const policy = immutable(structuredClone(host.policy))
36
+ demand(validPolicy(policy), 'policy-unavailable')
37
+ const policyDigest = edgeDigest(policy)
38
+ demand(typeof host.bindingId === 'string' && host.bindingId.length > 0, 'snapshot-binding-unavailable')
39
+ const base = { ...identity, family: record.family, policyDigest, verification: { checkedAt: host.now, bindingId: host.bindingId }, reviewRefs: [], triple: { subject: record.subject, predicate: record.predicate, object: record.object },
40
+ supportRefs: edgeEvidenceOccurrences(record), qualifications: record.qualifications,
41
+ sourceChange: record.sourceChange, history: events }
42
+ const result = (value) => seal({ ...base, ...value })
43
+ if (record.policyDigest !== policyDigest) return result(fail('suspended', 'policy-changed'))
44
+ const now = Date.parse(host.now)
45
+ demand(Number.isFinite(now), 'clock-unavailable')
46
+ const head = await host.readRecordHead(identity)
47
+ if (!head || head.recordDigest !== identity.recordDigest || head.eventCount !== events.length || head.eventDigest !== (events.length ? edgeDigest(events.at(-1)) : null)) return result(fail('suspended', 'history-head-mismatch'))
48
+ const origin = await host.verifyOrigin({ record, ...identity, policyDigest })
49
+ if (!origin || origin.recordDigest !== identity.recordDigest || origin.bindingId !== host.bindingId || origin.matches !== true) return result(fail('suspended', 'origin-mismatch'))
50
+ // Safe placement considers the home's entire intended readership, not only
51
+ // this reader. The host checks endpoints, all evidence and record metadata.
52
+ const placement = await host.checkSourceHome({ record, ...identity, policyDigest })
53
+ if (!placement || placement.recordDigest !== identity.recordDigest || placement.policyDigest !== policyDigest || placement.bindingId !== host.bindingId || placement.allowed !== true) return result(fail('suspended', 'unsafe-source-home'))
54
+ const endpoints = await Promise.all([record.subject, record.object].map((ref) => host.resolveEndpoint(ref)))
55
+ if (endpoints.some((e, i) => !e || !same(e.ref, [record.subject, record.object][i]) || e.current !== true || typeof e.stewardId !== 'string' || !e.stewardId)) return result(fail('suspended', 'endpoint-unavailable'))
56
+ const home = await host.resolveHomeOwner(record.home.repoId)
57
+ if (!home || typeof home.actorId !== 'string' || !home.actorId) return result(fail('suspended', 'home-owner-unavailable'))
58
+ for (const { source } of base.supportRefs) {
59
+ const observed = await host.readSource(source)
60
+ if (!observed || observed.revision !== source.revision || !(observed.bytes instanceof Uint8Array)) return result(fail('stale', 'source-changed'))
61
+ const bytes = Buffer.from(observed.bytes)
62
+ if (bytesDigest(bytes) !== source.fileDigest || source.locator.byteEnd > bytes.length ||
63
+ bytesDigest(bytes.subarray(source.locator.byteStart, source.locator.byteEnd)) !== source.citedDigest) return result(fail('stale', 'citation-changed'))
64
+ }
65
+ const semantic = record.family !== 'hyperlink'
66
+ const crossRepo = record.subject.repoId !== record.object.repoId
67
+ const governed = semantic && (crossRepo || record.family === 'reviewed-assertion' || events.length > 0)
68
+ if (!semantic) return result(events.length ? fail('suspended', 'navigation-has-decisions') : { state: 'active', eligible: true, assurance: 'navigation' })
69
+ if (!governed) return result({ state: 'active', eligible: true, assurance: 'source-declaration' })
70
+ if (!record.proposalRef || !record.sourceChange || record.sourceChange.repoId !== record.home.repoId || record.sourceChange.path !== record.home.path) return result(fail('suspended', 'source-owner-handoff-missing'))
71
+ // Review roles in this adapter are repository-scoped. Conflicting resource
72
+ // stewards inside one repository cannot be collapsed into one approval.
73
+ if (!crossRepo && endpoints[0].stewardId !== endpoints[1].stewardId) return result(fail('suspended', 'endpoint-steward-conflict'))
74
+ const required = [{ repoId: record.home.repoId, role: 'home-owner', actorId: home.actorId },
75
+ ...[...new Map(endpoints.map((e) => [e.ref.repoId, { repoId: e.ref.repoId, role: 'endpoint-steward', actorId: e.stewardId }])).values()]]
76
+ if (crossRepo && endpoints[0].stewardId === endpoints[1].stewardId && !policy.allowSharedSteward) return result(fail('suspended', 'shared-steward-not-delegated'))
77
+ let previous = null, state = 'draft', proposer = null, lastTime = -Infinity
78
+ const approvals = new Map()
79
+ for (const [index, event] of events.entries()) {
80
+ if (event.sequence !== index + 1 || event.previousDigest !== previous || event.recordDigest !== identity.recordDigest || event.policyDigest !== policyDigest || Date.parse(event.at) < lastTime || Date.parse(event.at) > now) return result(fail('suspended', 'decision-chain-invalid'))
81
+ if (['withdrawn', 'rejected', 'superseded', 'suspended'].includes(state)) return result(fail('suspended', 'terminal-history-continued'))
82
+ const binding = await host.resolveActorKey({ actorId: event.actorId, keyId: event.signature.keyId, at: host.now })
83
+ if (!binding || binding.actorId !== event.actorId || binding.revoked !== false || !Number.isFinite(Date.parse(binding.notBefore)) || !Number.isFinite(Date.parse(binding.expiresAt)) ||
84
+ Date.parse(binding.notBefore) > Date.parse(event.at) || Date.parse(binding.expiresAt) <= now || !verifyDocument(event, { publicKey: binding.publicKey }).valid) return result(fail('suspended', 'actor-key-unavailable'))
85
+ const grant = await host.authorizeDecision({ event, record, required, policyDigest })
86
+ if (!grant || grant.eventDigest !== edgeDigest(event) || grant.policyDigest !== policyDigest || grant.bindingId !== host.bindingId || grant.allowed !== true || grant.revoked !== false ||
87
+ !Number.isFinite(Date.parse(grant.notBefore)) || Date.parse(grant.notBefore) > Date.parse(event.at) || !Number.isFinite(Date.parse(grant.expiresAt)) || Date.parse(grant.expiresAt) <= now) return result(fail('suspended', 'decision-grant-unavailable'))
88
+ if (event.action === 'propose') {
89
+ if (state !== 'draft') return result(fail('suspended', 'invalid-transition'))
90
+ proposer = event.actorId; state = 'awaiting-endpoint-review'
91
+ } else if (event.action === 'approve') {
92
+ if (state !== 'awaiting-endpoint-review' || (!policy.allowProposerReview && event.actorId === proposer)) return result(fail('suspended', 'invalid-reviewer'))
93
+ for (const role of event.roles) {
94
+ const match = required.find((r) => roleKey(r) === roleKey(role) && r.actorId === event.actorId)
95
+ if (!match) return result(fail('suspended', 'invalid-reviewer'))
96
+ approvals.set(roleKey(role), edgeDigest(event))
97
+ }
98
+ } else if (event.action === 'accept') {
99
+ if (state !== 'awaiting-endpoint-review' || event.actorId !== home.actorId || required.some((r) => !approvals.has(roleKey(r)))) return result(fail('suspended', 'missing-reviewer'))
100
+ state = 'active'
101
+ } else {
102
+ if (state === 'draft' || ![home.actorId, proposer, ...endpoints.map((e) => e.stewardId)].includes(event.actorId)) return result(fail('suspended', 'invalid-transition'))
103
+ state = { reject: 'rejected', suspend: 'suspended', withdraw: 'withdrawn', supersede: 'superseded' }[event.action]
104
+ }
105
+ previous = edgeDigest(event); lastTime = Date.parse(event.at)
106
+ }
107
+ return result({ state, eligible: state === 'active', assurance: state === 'active' ? 'reviewed-assertion' : 'pending', reviewRefs: [...new Set(approvals.values())].sort() })
108
+ } catch {
109
+ // Port errors must not disclose hidden paths, actor details or source bytes.
110
+ return immutable(fail('suspended', 'verification-unavailable'))
111
+ }
112
+ }
113
+
114
+ // Input results must already be authorized for one recipient and evaluated in
115
+ // the same host snapshot. Preserve each complete assertion and its supports.
116
+ export function groupEligibleEdges(results) {
117
+ const groups = new Map()
118
+ let binding = null
119
+ for (const result of results) {
120
+ if (!decisions.has(result)) throw new Error('unverified edge decision')
121
+ if (!result.eligible || result.state !== 'active') continue
122
+ const current = edgeDigest({ bindingId: result.verification.bindingId, policyDigest: result.policyDigest })
123
+ if (binding !== null && binding !== current) throw new Error('mixed edge decision bindings')
124
+ binding = current
125
+ const key = edgeDigest({ family: result.family, triple: result.triple, qualifications: result.qualifications })
126
+ const group = groups.get(key) ?? { groupId: `edge-group:${key.slice(7)}`, family: result.family, triple: result.triple, assertions: [] }
127
+ if (group.assertions.some((a) => a.edgeId === result.edgeId && edgeDigest(a) !== edgeDigest(result))) throw new Error('conflicting edge decisions')
128
+ if (!group.assertions.some((a) => a.edgeId === result.edgeId)) group.assertions.push(structuredClone(result))
129
+ groups.set(key, group)
130
+ }
131
+ return immutable([...groups.values()].map((g) => ({ ...g, assertions: g.assertions.sort((a, b) => a.edgeId.localeCompare(b.edgeId, 'en')) })).sort((a, b) => a.groupId.localeCompare(b.groupId, 'en')))
132
+ }
133
+
134
+ // Only evaluated decisions may enter this seam; a deserialized eligible flag is
135
+ // never authority. The access host revalidates bindingId/current grants at use.
136
+ export function displayEligibleEdge(result) {
137
+ if (!decisions.has(result) || !result.eligible || result.state !== 'active') return null
138
+ return immutable({ edgeId: result.edgeId, source: result.triple.subject, target: result.triple.object,
139
+ predicate: result.triple.predicate, family: result.family, acceptanceState: result.state, assurance: result.assurance,
140
+ supportRefs: result.supportRefs.map((s) => s.id), sourceRef: result.supportRefs[0].id, reviewRefs: result.reviewRefs,
141
+ qualifications: result.qualifications, recordDigest: result.recordDigest, policyDigest: result.policyDigest, verification: result.verification })
142
+ }
@@ -0,0 +1,43 @@
1
+ import { canonicalize } from '../attestation/jcs.mjs'
2
+ import { validateRepositoryContribution } from '../graph/repo-contribution.mjs'
3
+ import { validSourceRef } from './contract.mjs'
4
+
5
+ const need = (value) => { if (!value) throw new TypeError('Invalid source citation input') }
6
+
7
+ /**
8
+ * Convert an exact observed occurrence to the policy's raw-byte citation shape.
9
+ * This pure structural binding does not authenticate source bytes, source-home
10
+ * custody, freshness or a declared triple. The policy host must still implement
11
+ * readSource, verifyOrigin and checkSourceHome against its current snapshot.
12
+ * Original provenance, including UTF16 offsets and any pointer/target range,
13
+ * stays unchanged in the contribution. Only its outer raw-byte range is cited.
14
+ * @param {import('../graph/repo-contribution.types.mjs').RepositoryContribution} contribution
15
+ * @param {import('../graph/repo-contribution.types.mjs').SourceRef} provenance
16
+ */
17
+ export function sourceCitationFromProvenance(contribution, provenance) {
18
+ try {
19
+ need(validateRepositoryContribution(contribution).ok)
20
+ const { snapshot, repository } = contribution
21
+ need(provenance?.repoId === repository.repoId && provenance.revision === snapshot.digest)
22
+ const file = snapshot.files.find((pin) => pin.path === provenance.path)
23
+ need(file && provenance.digest === file.rawDigest)
24
+ const { range, rangeDigest } = provenance.locator ?? {}
25
+ need(range && Number.isSafeInteger(range.byteStart) && range.byteStart >= 0 &&
26
+ Number.isSafeInteger(range.byteEnd) && range.byteEnd > range.byteStart && range.byteEnd <= file.byteLength)
27
+ // A matching file pin alone cannot invent a source occurrence. Accept a
28
+ // detached copy too, while retaining every field of the recorded provenance.
29
+ const observed = canonicalize(provenance)
30
+ need([...contribution.ordinaryLinks, ...contribution.declaredRelations]
31
+ .some((occurrence) => canonicalize(occurrence.provenance) === observed))
32
+ const citation = {
33
+ repoId: provenance.repoId, path: provenance.path, revision: provenance.revision,
34
+ fileDigest: provenance.digest,
35
+ locator: { byteStart: range.byteStart, byteEnd: range.byteEnd },
36
+ citedDigest: rangeDigest,
37
+ }
38
+ need(validSourceRef(citation))
39
+ return citation
40
+ } catch {
41
+ throw new TypeError('Invalid source citation input')
42
+ }
43
+ }
@@ -468,7 +468,8 @@ export function loadExtensionPacks(project, { report = false, verifyLock = true
468
468
  // Explicit registry object, no global state. Bundled resolution keeps its
469
469
  // existing id | slug | namespaced affordance; pack protocols resolve by full
470
470
  // namespaced id only, so a pack can never shadow or squat a bundled slug.
471
- export function createProtocolRegistry({ packs = [] } = {}) {
471
+ export function createProtocolRegistry({ packs = [], includeBundled = true } = {}) {
472
+ if (typeof includeBundled !== 'boolean') throw new TypeError('includeBundled must be a boolean')
472
473
  const packProtocols = new Map()
473
474
  for (const pack of packs) {
474
475
  for (const record of pack.protocols ?? []) packProtocols.set(record.id, record)
@@ -476,14 +477,14 @@ export function createProtocolRegistry({ packs = [] } = {}) {
476
477
  return {
477
478
  packs,
478
479
  protocolById(id) {
479
- const bundled = getBundledReadinessProtocol(id)
480
+ const bundled = includeBundled ? getBundledReadinessProtocol(id) : null
480
481
  if (bundled) return bundled
481
482
  const record = packProtocols.get(id)
482
483
  return record ? record.protocol : null
483
484
  },
484
485
  listProtocols() {
485
486
  return [
486
- ...bundledReadinessProtocols,
487
+ ...(includeBundled ? bundledReadinessProtocols : []),
487
488
  ...[...packProtocols.values()].map((record) => record.protocol),
488
489
  ]
489
490
  },
@@ -0,0 +1,83 @@
1
+ import { canonicalJson, exact, ExtensionError } from './contracts.mjs'
2
+
3
+ function joinBytes(chunks, size) {
4
+ const bytes = new Uint8Array(size)
5
+ let offset = 0
6
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
7
+ return bytes
8
+ }
9
+ async function cancelResponseBody(body) {
10
+ if (!body) return
11
+ let timer
12
+ try {
13
+ await Promise.race([
14
+ Promise.resolve().then(() => body.cancel()).catch(() => {}),
15
+ new Promise(resolve => { timer = setTimeout(resolve, 100) }),
16
+ ])
17
+ } finally { clearTimeout(timer) }
18
+ }
19
+ export async function readInvocationBody(request) {
20
+ const body = request.body?.getReader()
21
+ if (!body) throw new ExtensionError('invalid-input')
22
+ try {
23
+ let size = 0
24
+ const chunks = []
25
+ for (;;) {
26
+ const { value, done } = await body.read()
27
+ if (done) break
28
+ size += value.byteLength
29
+ if (size > 65536) throw new ExtensionError('invalid-input')
30
+ chunks.push(value)
31
+ }
32
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joinBytes(chunks, size)))
33
+ } finally { await body.cancel().catch(() => {}) }
34
+ }
35
+ export function createRootAdmissionConsumer(options) {
36
+ return createAdmissionConsumer(options, options.allowLocalTesting && ['test', 'development'].includes(process.env.NODE_ENV))
37
+ }
38
+ export function createAdmissionConsumer({ origin, fetch: hostFetch = globalThis.fetch }, localTestingAuthorized = false) {
39
+ const target = new URL(origin)
40
+ const local = localTestingAuthorized &&
41
+ target.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(target.hostname)
42
+ if ((target.protocol !== 'https:' && !local) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) throw new ExtensionError('invalid-input')
43
+ const endpoint = new URL('/api/root-runtime/extensions/admissions/consume', target)
44
+ return async (_claims, delegation) => {
45
+ // Convex supports AbortController and action timers, but not every runtime
46
+ // implements AbortSignal.timeout. The deadline includes the response body.
47
+ const controller = new AbortController()
48
+ let timer, response, reader
49
+ const expired = new Promise((_, reject) => {
50
+ timer = setTimeout(() => { controller.abort(); reject(new ExtensionError('unavailable', 503)) }, 5000)
51
+ })
52
+ const consume = async () => {
53
+ response = await hostFetch(endpoint, { method: 'POST', redirect: 'error', signal: controller.signal,
54
+ headers: { 'content-type': 'application/json' }, body: canonicalJson({ delegation }) })
55
+ if (response.status === 401 || response.status === 403) return false
56
+ if (response.status != 200 || response.headers.get('content-type')?.split(';')[0] !== 'application/json') throw new ExtensionError('unavailable', 503)
57
+ reader = response.body?.getReader()
58
+ if (!reader) throw new ExtensionError('unavailable', 503)
59
+ try {
60
+ let size = 0
61
+ const chunks = []
62
+ for (;;) {
63
+ const { value, done } = await reader.read()
64
+ if (done) break
65
+ size += value.byteLength
66
+ if (size > 1024) throw new ExtensionError('unavailable', 503)
67
+ chunks.push(value)
68
+ }
69
+ const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joinBytes(chunks, size)))
70
+ exact(body, ['admitted'])
71
+ if (body.admitted !== true) throw new ExtensionError('unavailable', 503)
72
+ return true
73
+ } catch { throw new ExtensionError('unavailable', 503) }
74
+ }
75
+ try { return await Promise.race([consume(), expired]) } finally {
76
+ clearTimeout(timer)
77
+ // Also close refusal/error responses, whose body reader was never acquired.
78
+ // Abort first; a broken/custom stream cancellation cannot extend transport.
79
+ controller.abort()
80
+ await cancelResponseBody(reader ?? response?.body)
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionReleaseBundle, ExtensionReleaseRegistration, ExtensionManifest, InstallationBinding, Invocation, Json, OperationReceipt } from './contracts.mjs'
2
+ export interface HostTransport {
3
+ getInstallation(id: string): Promise<InstallationBinding>;
4
+ invoke(request: Invocation): Promise<OperationReceipt>;
5
+ lookupOperation(id: string, key: string, action: string): Promise<OperationReceipt | null>;
6
+ recoverOperation(binding: InstallationBinding, key: string, action: string): Promise<OperationReceipt>;
7
+ recordResult(operationId: string): Promise<void>;
8
+ }
9
+ export interface AdminHostTransport {
10
+ registerRelease?(bundle: ExtensionReleaseBundle): Promise<ExtensionReleaseRegistration>;
11
+ configure(manifest: ExtensionManifest, connectionRef: string, environment: 'development' | 'staging' | 'production'): Promise<InstallationBinding>;
12
+ transition(binding: InstallationBinding, transition: 'verify' | 'enable' | 'disable' | 'remove'): Promise<InstallationBinding>;
13
+ upgrade(binding: InstallationBinding, manifest: ExtensionManifest): Promise<InstallationBinding>;
14
+ }
15
+ export function createHttpHostTransport(hostFetch: typeof fetch): HostTransport & AdminHostTransport
16
+ export function createExtensionClient(transport: HostTransport): {
17
+ getInstallation(id: string): Promise<InstallationBinding>;
18
+ invoke(binding: InstallationBinding, actionId: string, input: Json, idempotencyKey: string): Promise<OperationReceipt>;
19
+ lookupOperation(id: string, key: string, action: string): Promise<OperationReceipt | null>;
20
+ recoverOperation(binding: InstallationBinding, key: string, action: string): Promise<OperationReceipt>;
21
+ recordResult(operationId: string): Promise<void>;
22
+ }
23
+ export function createExtensionAdminClient(transport: AdminHostTransport): Omit<AdminHostTransport, 'configure' | 'registerRelease'> & { registerRelease(bundle: ExtensionReleaseBundle): Promise<ExtensionReleaseRegistration>; configure(manifest: ExtensionManifest, connectionRef: string, environment?: 'development' | 'staging' | 'production'): Promise<InstallationBinding> }