archgraph-argo 0.26.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,6 @@ const readline = require('node:readline');
6
6
  const crypto = require('node:crypto');
7
7
 
8
8
  const {
9
- getArgoRoot,
10
9
  resolveCallWorkspaceRoot,
11
10
  } = require('./argo-paths.js');
12
11
 
@@ -131,7 +130,13 @@ const {
131
130
  validateGraphSemantics,
132
131
  validateArchiMateEndpointMatrix,
133
132
  validateViewElementLimits,
133
+ validateAttributeContracts,
134
134
  } = require('./graph-semantics.js');
135
+ const {
136
+ loadSchemaBundleAndOntology,
137
+ resolveSchemaBundle,
138
+ resolveTypeEnums,
139
+ } = require('./schema-bundle.js');
135
140
  const {
136
141
  createProductionGraphRagRuntime,
137
142
  } = require('./graph-rag/productionGraphRagRuntime.js');
@@ -168,6 +173,7 @@ const {
168
173
  } = require('./neo4j-system-architecture-store.js');
169
174
 
170
175
  const losslessWriteGate = require('./lossless-write-gate.js');
176
+ const { EXTERNAL_READ_TOOLS } = require('./external-graph-query.js');
171
177
 
172
178
  const HANDLED_MUTATION_TYPES = new Set([
173
179
  'addElement',
@@ -221,17 +227,17 @@ const TOOLS = [
221
227
  },
222
228
  {
223
229
  name: 'getIntentElementContext',
224
- description: 'read-only query that returns an intent subgraph context for one element. Uses ArchiMate semantic dependency traversal with dependencyDepth and dependentDepth, preserving native subgraph elements, relationships, and views.',
230
+ description: 'read-only query that returns an intent subgraph context for one element. Uses ArchiMate semantic dependency traversal with dependencyDepth and dependentDepth, preserving native subgraph elements, relationships, and views. Output is bounded by maxBytes (default ARGO_CONTEXT_MAX_BYTES or 32000): beyond it non-focus members degrade to identity (id/type/name) and a complete id manifest is returned under truncation, so the host never silently cuts the payload. On a large/hub element leave includeAttributes/includeTestcases off (they are the ledger and can dominate the size) and prefer queryNeo4jGraph to locate ids, then read them narrowly.',
225
231
  inputSchema: intentElementContextInputSchema(),
226
232
  },
227
233
  {
228
234
  name: 'getArchitectureViewContext',
229
- description: 'read-only query that resolves one view by view_id into its complete membership: the view object, every member element (from included_elements), every member relationship (from included_relationships), the parent element, and optionally child sub-views declared by member elements. Resolves ids into full canonical objects instead of returning raw id lists. Optional includeEaGeometry (default false) additionally returns the EA diagram geometry of the resolved view: element boxes plus each connector ROUTE (t_diagramlinks.Path as `path` + parsed `points`, with the non-route Geometry override kept separately under `geometry`).',
235
+ description: 'read-only query that resolves one view by view_id into its complete membership: the view object, every member element (from included_elements), every member relationship (from included_relationships), the parent element, and optionally child sub-views declared by member elements. Resolves ids into full canonical objects instead of returning raw id lists. Output is bounded by maxBytes (default ARGO_CONTEXT_MAX_BYTES or 32000): beyond it members degrade to identity (id/type/name) with a complete id manifest under truncation. Optional includeEaGeometry (default false) additionally returns the EA diagram geometry of the resolved view: element boxes plus each connector ROUTE (t_diagramlinks.Path as `path` + parsed `points`, with the non-route Geometry override kept separately under `geometry`).',
230
236
  inputSchema: viewContextInputSchema(),
231
237
  },
232
238
  {
233
239
  name: 'previewSystemArchitectureMutation',
234
- description: 'Use before apply for complex or risky changes. Performs a dry-run of one or more mutations, runs schema, graph, view, and ArchiMate 3.2 validation, and does not write the graph.',
240
+ description: 'Use before apply for complex or risky changes. Performs a dry-run of one or more mutations, runs schema, graph, view, and modeling-language validation, and does not write the graph.',
235
241
  inputSchema: mutationInputSchema(),
236
242
  },
237
243
  {
@@ -241,12 +247,18 @@ const TOOLS = [
241
247
  },
242
248
  {
243
249
  name: 'addArchitectureElement',
244
- description: 'Use for one element. Creates a new element or adds an existing element to view_ids. view_ids is required so elements never exist outside views. Set dryRun to preview without writing.',
250
+ description: 'Use for one element. Creates a new element or adds an existing element to view_ids. view_ids is required so elements never exist outside views. element.id is OPTIONAL: omit it and the server auto-allocates a unique id (returned in the result); provide it to pin a specific id (must not collide with a different element). Set dryRun to preview without writing.',
245
251
  inputSchema: {
246
252
  type: 'object',
247
253
  required: ['element', 'view_ids'],
248
254
  properties: {
249
- element: { type: 'object' },
255
+ element: {
256
+ type: 'object',
257
+ properties: {
258
+ id: { type: 'string', description: 'Element id (OPTIONAL). Omit to let the server auto-allocate a unique id (a semantic slug from the name, e.g. "graph-wiki-federation-center", suffixed -002.. if taken). If provided it must not collide with a DIFFERENT element (a same (type,name) match is idempotent reuse).' },
259
+ },
260
+ description: 'The element to create/attach. `id` is optional — omit it and the server allocates one (returned in the result).',
261
+ },
250
262
  view_ids: { type: 'array', minItems: 1, items: { type: 'string' } },
251
263
  onConflict: { type: 'string', enum: ['reuse', 'allowDuplicate'], description: 'Dedup policy (default reuse). reuse: find-or-create — attach an existing exact (type, name) match; a same-type semantic near-duplicate also blocks creation. allowDuplicate: create anyway (even if a duplicate exists), requires a justification.' },
252
264
  justification: { type: 'string', description: 'Required when onConflict is allowDuplicate.' },
@@ -288,7 +300,7 @@ const TOOLS = [
288
300
  },
289
301
  {
290
302
  name: 'addArchitectureRelationship',
291
- description: 'Use for one relationship. Creates a new relationship or adds an existing relationship to view_ids. relationship.type is the ArchiMate 3.2 relationship type and is validated against endpoint element types. Set dryRun to preview without writing.',
303
+ description: 'Use for one relationship. Creates a new relationship or adds an existing relationship to view_ids. relationship.type is the ArchiMate 3.2 relationship type and is validated against endpoint element types. relationship.id is OPTIONAL: omit it and the server auto-allocates a unique id (returned in the result). Set dryRun to preview without writing.',
292
304
  inputSchema: {
293
305
  type: 'object',
294
306
  required: ['relationship', 'view_ids'],
@@ -335,7 +347,7 @@ const TOOLS = [
335
347
  },
336
348
  {
337
349
  name: 'addArchitectureView',
338
- description: 'Use for one view. The graph must have exactly one top-level view named SystemArchitecture; all sub-views must attach to an element with parent_element_id. Set dryRun to preview without writing.',
350
+ description: 'Use for one view. The graph must have exactly one top-level view named SystemArchitecture; all sub-views must attach to an element with parent_element_id. view.view_id is OPTIONAL: omit it and the server auto-allocates a unique view_id (returned in the result). Set dryRun to preview without writing.',
339
351
  inputSchema: {
340
352
  type: 'object',
341
353
  required: ['view'],
@@ -380,7 +392,7 @@ const TOOLS = [
380
392
  },
381
393
  {
382
394
  name: 'queryNeo4jGraph',
383
- description: 'Run a read-only Cypher query against the Neo4j structural projection of the intent architecture, or request the projection schema so an agent can construct its own Cypher. Pass {schema: true} to return node labels, relationship types, property keys, and the legal ArchiMate element/relationship type enums. Pass {cypher: "..."} to execute a read-only query; scope it with {graphKey: $graphKey}. Write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/LOAD CSV/FOREACH/IN TRANSACTIONS) are rejected.',
395
+ description: 'Run a read-only Cypher query against the Neo4j structural projection of the intent architecture, or request the projection schema so an agent can construct its own Cypher. Pass {schema: true} to return node labels, relationship types, property keys, and the legal element/relationship type enums of the workspace-resolved schema bundle. Pass {cypher: "..."} to execute a read-only query; scope it with {graphKey: $graphKey}. Write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/LOAD CSV/FOREACH/IN TRANSACTIONS) are rejected.',
384
396
  inputSchema: {
385
397
  type: 'object',
386
398
  properties: {
@@ -415,6 +427,13 @@ const WORKSPACE_ROOT_PARAM = Object.freeze({
415
427
  description:
416
428
  'Optional absolute workspace root for this call. When provided it is used as-is; otherwise the server launch directory is used.',
417
429
  });
430
+ // Cross-project graph query: the 5 read tools accept an optional `projectId`;
431
+ // provided => routed to the federation center, omitted => the local workspace.
432
+ const PROJECT_ID_PARAM = Object.freeze({
433
+ type: 'string',
434
+ description:
435
+ 'Optional external project id. When provided, the query is routed to the federation center to read that project\'s graph (this project must be registered in .argo/federation.json); omitted = the local workspace (unchanged).',
436
+ });
418
437
  // Lossless write gate: every update/remove helper accepts an explicit loss
419
438
  // acknowledgement (see lossless-write-gate.js). add* is purely additive and is
420
439
  // left alone. Without the acknowledgement a shrinking text edit or a destructive
@@ -436,6 +455,9 @@ for (const tool of TOOLS) {
436
455
  if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, 'workspaceRoot')) {
437
456
  inputSchema.properties.workspaceRoot = WORKSPACE_ROOT_PARAM;
438
457
  }
458
+ if (EXTERNAL_READ_TOOLS.has(tool.name) && !Object.prototype.hasOwnProperty.call(inputSchema.properties, 'projectId')) {
459
+ inputSchema.properties.projectId = PROJECT_ID_PARAM;
460
+ }
439
461
  if (tool.name && /^(update|remove)Architecture/.test(tool.name)) {
440
462
  for (const [key, value] of Object.entries(LOSS_ACK_INPUT_PROPS)) {
441
463
  if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, key)) {
@@ -461,8 +483,9 @@ function intentElementContextInputSchema() {
461
483
  dependentDepth: { type: 'number', description: 'Default: 1. Semantic dependents that rely on the focus element.' },
462
484
  associationDepth: { type: 'number', description: 'Default: 1. Association neighbors are expanded at least one layer.' },
463
485
  associationNeighborDependencyDepth: { type: 'number', description: 'Default: 0. Optional dependency expansion from association neighbors.' },
464
- includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim. Omitted by default from this structural read; the focus element always keeps its own. Semantic retrieval embeds attributes, so semantic hits keep them.' },
465
- includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim. Omitted by default from this structural read (bookkeeping); pass true when you need acceptance cases. Semantic retrieval embeds testcase descriptions, so semantic hits keep them.' },
486
+ includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim. Omitted by default from this structural read; the focus element always keeps its own. Semantic retrieval embeds attributes, so semantic hits keep them. On a hub element this ledger can dominate the payload — prefer leaving it off and reading bookkeeping narrowly.' },
487
+ includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim. Omitted by default from this structural read (bookkeeping); pass true when you need acceptance cases. Semantic retrieval embeds testcase descriptions, so semantic hits keep them. On a hub element this can be large — prefer leaving it off.' },
488
+ maxBytes: { type: 'number', description: 'Optional output budget in UTF-8 bytes (the exact serialized response). Default: ARGO_CONTEXT_MAX_BYTES env or 32000. 0 = unlimited. When the payload exceeds the budget, non-focus members degrade to identity (id/type/name) and a complete id manifest is returned under `truncation` — the payload is never silently truncated by the host.' },
466
489
  },
467
490
  additionalProperties: false,
468
491
  };
@@ -480,6 +503,7 @@ function viewContextInputSchema() {
480
503
  includeAttributes: { type: 'boolean', description: 'Default: false. Include member/relationship `attributes` (commit/session/release ledgers) verbatim. Omitted by default from this structural read (bookkeeping); pass true when you need provenance.' },
481
504
  includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim. Omitted by default from this structural read; pass true for acceptance-case lookups.' },
482
505
  includeEaGeometry: { type: 'boolean', description: 'Default: false (opt-in). When true, additionally resolve the diagram GEOMETRY (element boxes + connector line routes) for this view from the workspace EA model (.qea) and return it under a `geometry` field aligned by schema id with the resolved members. Each geometry relationship carries: `path` (the EA route from t_diagramlinks.Path, "" when EA auto-routes), `points` (the parsed [{x,y}] waypoints), `edge` (the EDGE route-style token or null) and `geometry` (the raw SX/SY/EX/EY override string, which contains NO waypoints). By default the EA model is never touched and no `geometry` field is returned; a missing EA model/diagram yields geometry.present=false, never an error.' },
506
+ maxBytes: { type: 'number', description: 'Optional output budget in UTF-8 bytes (the exact serialized response). Default: ARGO_CONTEXT_MAX_BYTES env or 32000. 0 = unlimited. When the payload exceeds the budget, members degrade to identity (id/type/name) and a complete id manifest is returned under `truncation` — the payload is never silently truncated by the host.' },
483
507
  },
484
508
  additionalProperties: false,
485
509
  };
@@ -559,22 +583,7 @@ function normalizeRelativePath(value) {
559
583
  }
560
584
 
561
585
  function resolveSchemaPath(workspaceRoot) {
562
- const bundledSchemaPath = path.join(getArgoRoot(), 'schema', 'SystemArchitecture.schema.json');
563
- if (fs.existsSync(bundledSchemaPath)) {
564
- return { absolutePath: bundledSchemaPath, relativePath: SCHEMA_PATH_CANDIDATES[0] };
565
- }
566
-
567
- for (const candidate of SCHEMA_PATH_CANDIDATES) {
568
- const absolutePath = path.join(workspaceRoot, candidate);
569
- if (fs.existsSync(absolutePath)) {
570
- return { absolutePath, relativePath: candidate };
571
- }
572
- const bundledPath = path.resolve(__dirname, '..', '..', candidate);
573
- if (fs.existsSync(bundledPath)) {
574
- return { absolutePath: bundledPath, relativePath: candidate };
575
- }
576
- }
577
- throw new Error(`Unable to locate SystemArchitecture schema. Checked: ${SCHEMA_PATH_CANDIDATES.join(', ')}`);
586
+ return resolveSchemaBundle(workspaceRoot).schema;
578
587
  }
579
588
 
580
589
  function readJson(filePath, label) {
@@ -588,13 +597,17 @@ function readJson(filePath, label) {
588
597
  async function loadContext(args = {}) {
589
598
  const workspaceRoot = resolveWorkspaceRoot(args);
590
599
  const graphPath = resolveWorkspacePath(workspaceRoot, args.architecturePath || DEFAULT_GRAPH_PATH);
591
- const schemaPath = resolveSchemaPath(workspaceRoot);
600
+ // Resolve the modeling language for THIS workspace: a repository that ships
601
+ // its own bundle under .argo/schema is validated against that schema.
602
+ const { bundle, ontology } = loadSchemaBundleAndOntology(workspaceRoot);
592
603
  const context = {
593
604
  workspaceRoot,
594
605
  graphPath,
595
- schemaPath,
606
+ schemaPath: bundle.schema,
607
+ schemaBundle: bundle,
608
+ ontology,
596
609
  document: readJson(graphPath.absolutePath, graphPath.relativePath),
597
- schema: readJson(schemaPath.absolutePath, schemaPath.relativePath),
610
+ schema: bundle.schemaDocument,
598
611
  };
599
612
  context.neo4jSyncRecovery = await recoverNeo4jSyncIfNeeded({
600
613
  architecturePath: graphPath.relativePath,
@@ -605,13 +618,20 @@ async function loadContext(args = {}) {
605
618
  }
606
619
 
607
620
  function validateDocument(document, schema, options = {}) {
621
+ const ontology = options.ontology;
608
622
  const errors = [];
623
+ if (ontology && ontology.bundleValidation && ontology.bundleValidation.status === 'failed') {
624
+ errors.push(...ontology.bundleValidation.errors.map(error => `schema bundle: ${error}`));
625
+ }
609
626
  validateAgainstSchema(document, schema, '#', errors, schema);
610
- validateGraphSemantics(document, errors);
627
+ validateGraphSemantics(document, errors, ontology);
628
+ validateAttributeContracts(document, errors, ontology);
611
629
  validateArchiMateEndpointMatrix(document, errors, {
630
+ ontology,
612
631
  touchedRelationshipIds: options.touchedRelationshipIds,
613
632
  });
614
633
  validateViewElementLimits(document, errors, {
634
+ ontology,
615
635
  touchedViewIds: options.validateAllViewElementLimits
616
636
  ? (document.views || []).map(view => view && view.view_id)
617
637
  : options.touchedViewIds,
@@ -668,6 +688,233 @@ function buildAgentProjection(omitted) {
668
688
  };
669
689
  }
670
690
 
691
+ // ---------------------------------------------------------------------------
692
+ // Context output budget (bounded, well-formed structural reads)
693
+ // ---------------------------------------------------------------------------
694
+ // A hub element/view can expand a semantic subgraph to tens of KB, which the
695
+ // HOST then cuts mid-JSON — the agent receives a malformed partial observation.
696
+ // This budget makes the ceiling the framework's, not the host's: above maxBytes
697
+ // the non-focus members degrade to identity (id/type/name), the focus element +
698
+ // boundary/explorationHints are kept, and EVERY included id is listed under
699
+ // `truncation` — so the payload is always valid JSON and always navigable.
700
+ // Default (or ARGO_CONTEXT_MAX_BYTES); 0 = unlimited. Semantic retrieval is NOT
701
+ // affected (only these two structural builders).
702
+ const DEFAULT_CONTEXT_MAX_BYTES = 32000;
703
+ const CONTEXT_BUDGET_NOTE =
704
+ 'The full payload exceeded maxBytes, so non-focus members are reduced to identity '
705
+ + '(id/type/name). The focus element and boundary/explorationHints are kept and every '
706
+ + 'included id is listed here. Raise maxBytes (0 = unlimited) or narrow the traversal '
707
+ + '(dependencyDepth/dependentDepth) to read details.';
708
+
709
+ function resolveContextMaxBytes(args = {}) {
710
+ const raw = args && args.maxBytes !== undefined ? args.maxBytes : process.env.ARGO_CONTEXT_MAX_BYTES;
711
+ if (raw === undefined || raw === null || raw === '') {
712
+ return DEFAULT_CONTEXT_MAX_BYTES;
713
+ }
714
+ const numeric = Number(raw);
715
+ if (!Number.isFinite(numeric) || numeric < 0) {
716
+ return DEFAULT_CONTEXT_MAX_BYTES;
717
+ }
718
+ return Math.floor(numeric);
719
+ }
720
+
721
+ // Budget is measured on the EXACT serialization the caller receives
722
+ // (`toolResult` pretty-prints with 2 spaces), in UTF-8 bytes.
723
+ function payloadByteLength(value) {
724
+ try { return Buffer.byteLength(JSON.stringify(value, null, 2)); } catch (_) { return 0; }
725
+ }
726
+
727
+ function identityElementRecord(element) {
728
+ return { id: element.id, name: element.name, type: element.type };
729
+ }
730
+
731
+ function identityRelationshipRecord(relationship) {
732
+ return {
733
+ id: relationship.id,
734
+ name: relationship.name,
735
+ type: relationship.type,
736
+ source_id: relationship.source_id,
737
+ target_id: relationship.target_id,
738
+ };
739
+ }
740
+
741
+ function identityViewRecord(view) {
742
+ return { view_id: view.view_id, view_name: view.view_name };
743
+ }
744
+
745
+ function budgetTruncationBase(result, maxBytes, fullBytes, ids) {
746
+ return {
747
+ truncated: true,
748
+ reason: 'context_budget_exceeded',
749
+ maxBytes,
750
+ fullBytes,
751
+ includedElementIds: ids.elements,
752
+ includedRelationshipIds: ids.relationships,
753
+ includedViewIds: ids.views,
754
+ note: CONTEXT_BUDGET_NOTE,
755
+ };
756
+ }
757
+
758
+ // Shrink the manifest id lists (last resort) so even a pathological hub stays
759
+ // within budget. The focus id is never dropped; `manifestTruncated` records any
760
+ // real loss of a listed id.
761
+ function trimManifestToBudget(result, maxBytes) {
762
+ const truncation = result.truncation;
763
+ const focusId = result.focusElementId;
764
+ // If the FOCUS element alone already exceeds the budget, trimming the (cheap)
765
+ // id manifest cannot get us under it — and dropping ids would only lose
766
+ // navigation for nothing. Keep the COMPLETE manifest and say why.
767
+ const focusOnly = {
768
+ ...result,
769
+ truncation: {
770
+ ...truncation,
771
+ includedElementIds: focusId ? [focusId] : [],
772
+ includedRelationshipIds: [],
773
+ includedViewIds: [],
774
+ },
775
+ };
776
+ if (focusId && payloadByteLength(focusOnly) > maxBytes) {
777
+ truncation.overBudgetByFocus = true;
778
+ return;
779
+ }
780
+ const arrays = ['includedElementIds', 'includedRelationshipIds', 'includedViewIds'];
781
+ for (let ratio = 0.9; ratio >= 0; ratio -= 0.1) {
782
+ for (const key of arrays) {
783
+ const full = truncation[key] || [];
784
+ const kept = full.slice(0, Math.ceil(full.length * ratio));
785
+ if (key === 'includedElementIds' && focusId && !kept.includes(focusId)) {
786
+ kept.unshift(focusId);
787
+ }
788
+ truncation[key] = kept;
789
+ }
790
+ truncation.manifestTruncated = true;
791
+ if (payloadByteLength(result) <= maxBytes) return;
792
+ }
793
+ truncation.manifestTruncated = true;
794
+ }
795
+
796
+ function applyContextBudget(result, maxBytes) {
797
+ if (!result || result.status !== 'passed' || !maxBytes || maxBytes <= 0) {
798
+ return result;
799
+ }
800
+ const fullBytes = payloadByteLength(result);
801
+ if (fullBytes <= maxBytes) {
802
+ return result;
803
+ }
804
+
805
+ const subgraph = result.subgraph || {};
806
+ const elements = Array.isArray(subgraph.elements) ? subgraph.elements : [];
807
+ const relationships = Array.isArray(subgraph.relationships) ? subgraph.relationships : [];
808
+ const views = Array.isArray(subgraph.views) ? subgraph.views : [];
809
+ const focusId = result.focusElementId;
810
+ const ids = {
811
+ elements: elements.map((entry) => entry.id),
812
+ relationships: relationships.map((entry) => entry.id),
813
+ views: views.map((entry) => entry.view_id),
814
+ };
815
+ const truncated = (tier) => budgetTruncationBase(result, maxBytes, fullBytes, ids);
816
+ const nonFocusCount = elements.filter((entry) => entry.id !== focusId).length;
817
+
818
+ const compact = {
819
+ ...result,
820
+ subgraph: {
821
+ ...subgraph,
822
+ elements: elements.map((entry) => (entry.id === focusId ? entry : identityElementRecord(entry))),
823
+ relationships: relationships.map(identityRelationshipRecord),
824
+ views: views.map(identityViewRecord),
825
+ },
826
+ truncation: { ...truncated('identity'), omittedElementDetails: nonFocusCount },
827
+ };
828
+ if (payloadByteLength(compact) <= maxBytes) {
829
+ return compact;
830
+ }
831
+
832
+ const idsOnly = {
833
+ ...result,
834
+ subgraph: {
835
+ ...subgraph,
836
+ elements: elements.map((entry) => (entry.id === focusId ? entry : { id: entry.id })),
837
+ relationships: relationships.map((entry) => ({ id: entry.id })),
838
+ views: views.map((entry) => ({ view_id: entry.view_id })),
839
+ },
840
+ truncation: truncated('ids-only'),
841
+ };
842
+ if (payloadByteLength(idsOnly) <= maxBytes) {
843
+ return idsOnly;
844
+ }
845
+
846
+ const manifestOnly = {
847
+ ...result,
848
+ subgraph: {
849
+ elements: elements.filter((entry) => entry.id === focusId),
850
+ relationships: [],
851
+ views: [],
852
+ },
853
+ truncation: truncated('manifest-only'),
854
+ };
855
+ trimManifestToBudget(manifestOnly, maxBytes);
856
+ return manifestOnly;
857
+ }
858
+
859
+ function applyViewContextBudget(result, maxBytes) {
860
+ if (!result || result.status !== 'passed' || !maxBytes || maxBytes <= 0) {
861
+ return result;
862
+ }
863
+ const fullBytes = payloadByteLength(result);
864
+ if (fullBytes <= maxBytes) {
865
+ return result;
866
+ }
867
+
868
+ const elements = Array.isArray(result.elements) ? result.elements : [];
869
+ const relationships = Array.isArray(result.relationships) ? result.relationships : [];
870
+ const childViews = Array.isArray(result.childViews) ? result.childViews : [];
871
+ const ids = {
872
+ elements: elements.map((entry) => entry.id),
873
+ relationships: relationships.map((entry) => entry.id),
874
+ views: childViews.map((entry) => entry.view_id),
875
+ };
876
+ const strippedView = result.view
877
+ ? { ...result.view, included_elements: [], included_relationships: [] }
878
+ : result.view;
879
+ const truncated = (tier) => ({ ...budgetTruncationBase(result, maxBytes, fullBytes, ids), tier });
880
+
881
+ const compact = {
882
+ ...result,
883
+ elements: elements.map(identityElementRecord),
884
+ relationships: relationships.map(identityRelationshipRecord),
885
+ parentElement: result.parentElement ? identityElementRecord(result.parentElement) : result.parentElement,
886
+ childViews: childViews.map(identityViewRecord),
887
+ truncation: { ...truncated('identity'), omittedElementDetails: elements.length },
888
+ };
889
+ if (payloadByteLength(compact) <= maxBytes) {
890
+ return compact;
891
+ }
892
+
893
+ const idsOnly = {
894
+ ...result,
895
+ elements: elements.map((entry) => ({ id: entry.id })),
896
+ relationships: relationships.map((entry) => ({ id: entry.id })),
897
+ parentElement: result.parentElement ? { id: result.parentElement.id } : result.parentElement,
898
+ childViews: childViews.map((entry) => ({ view_id: entry.view_id })),
899
+ truncation: truncated('ids-only'),
900
+ };
901
+ if (payloadByteLength(idsOnly) <= maxBytes) {
902
+ return idsOnly;
903
+ }
904
+
905
+ const manifestOnly = {
906
+ ...result,
907
+ view: strippedView,
908
+ elements: [],
909
+ relationships: [],
910
+ parentElement: null,
911
+ childViews: [],
912
+ truncation: truncated('manifest-only'),
913
+ };
914
+ trimManifestToBudget(manifestOnly, maxBytes);
915
+ return manifestOnly;
916
+ }
917
+
671
918
  function buildIntentElementContext(context, args = {}) {
672
919
  const profile = args.profile || 'generic-agent';
673
920
  const focusResult = resolveFocusElement(context.document, args);
@@ -679,7 +926,7 @@ function buildIntentElementContext(context, args = {}) {
679
926
  const dependentDepth = normalizeDepth(args.dependentDepth, 1);
680
927
  const associationDepth = Math.max(1, normalizeDepth(args.associationDepth, 1));
681
928
  const associationNeighborDependencyDepth = normalizeDepth(args.associationNeighborDependencyDepth, 0);
682
- const graphIndex = buildGraphIndex(context.document);
929
+ const graphIndex = buildGraphIndex(context.document, context.ontology);
683
930
  const focusElement = focusResult.element;
684
931
  const includedElementIds = new Set([focusElement.id]);
685
932
  const includedRelationshipIds = new Set();
@@ -794,7 +1041,7 @@ function buildIntentElementContext(context, args = {}) {
794
1041
  };
795
1042
  const projection = buildAgentProjection(omitted);
796
1043
  if (projection) result.projection = projection;
797
- return result;
1044
+ return applyContextBudget(result, resolveContextMaxBytes(args));
798
1045
  }
799
1046
 
800
1047
  // ---------------------------------------------------------------------------
@@ -938,7 +1185,7 @@ function buildViewContext(context, args = {}) {
938
1185
  }
939
1186
  const projection = buildAgentProjection(omitted);
940
1187
  if (projection) result.projection = projection;
941
- return result;
1188
+ return applyViewContextBudget(result, resolveContextMaxBytes(args));
942
1189
  }
943
1190
 
944
1191
  function resolveFocusElement(document, args) {
@@ -991,7 +1238,7 @@ function normalizeDepth(value, defaultValue) {
991
1238
  return Math.floor(numericValue);
992
1239
  }
993
1240
 
994
- function buildGraphIndex(document) {
1241
+ function buildGraphIndex(document, ontology) {
995
1242
  const relationshipById = new Map();
996
1243
  const elementById = new Map((document.elements || []).map(element => [element.id, element]));
997
1244
  const relationshipsByElementId = new Map();
@@ -1000,9 +1247,19 @@ function buildGraphIndex(document) {
1000
1247
  addIndexedRelationship(relationshipsByElementId, relationship.source_id, relationship);
1001
1248
  addIndexedRelationship(relationshipsByElementId, relationship.target_id, relationship);
1002
1249
  }
1003
- return { elementById, relationshipById, relationshipsByElementId };
1250
+ const deliveryDependencies = ontology && ontology.deliveryDependencies
1251
+ ? ontology.deliveryDependencies
1252
+ : DEFAULT_DELIVERY_DEPENDENCIES;
1253
+ return { elementById, relationshipById, relationshipsByElementId, deliveryDependencies };
1004
1254
  }
1005
1255
 
1256
+ // Dependency direction for the semantic-edge walk comes from the active schema
1257
+ // bundle's deliveryDependencies; this is the ArchiMate 3.2 fallback by default.
1258
+ const DEFAULT_DELIVERY_DEPENDENCIES = Object.freeze({
1259
+ sourceDependsOnTarget: Object.freeze(['Access', 'Assignment', 'Specialization', 'Composition', 'Aggregation']),
1260
+ targetDependsOnSource: Object.freeze(['Serving', 'Realization', 'Flow', 'Triggering', 'Influence']),
1261
+ });
1262
+
1006
1263
  function addIndexedRelationship(index, elementId, relationship) {
1007
1264
  if (!index.has(elementId)) {
1008
1265
  index.set(elementId, []);
@@ -1057,8 +1314,9 @@ function resolveSemanticEdges(elementId, graphIndex) {
1057
1314
  continue;
1058
1315
  }
1059
1316
 
1060
- const sourceDependsOnTarget = ['Access', 'Assignment', 'Specialization', 'Composition', 'Aggregation'].includes(relationshipType);
1061
- const targetDependsOnSource = ['Serving', 'Realization', 'Flow', 'Triggering', 'Influence'].includes(relationshipType);
1317
+ const deliveryDependencies = graphIndex.deliveryDependencies || DEFAULT_DELIVERY_DEPENDENCIES;
1318
+ const sourceDependsOnTarget = deliveryDependencies.sourceDependsOnTarget.includes(relationshipType);
1319
+ const targetDependsOnSource = deliveryDependencies.targetDependsOnSource.includes(relationshipType);
1062
1320
  if (sourceDependsOnTarget) {
1063
1321
  edges.push({ kind: isSource ? 'dependency' : 'dependent', neighborId, relationship });
1064
1322
  continue;
@@ -1368,6 +1626,207 @@ function resolveDuplicateConflict(options, candidates) {
1368
1626
  return { action: 'create', justification: options.justification };
1369
1627
  }
1370
1628
 
1629
+ function mutationNodeId(mutation) {
1630
+ if (!mutation || typeof mutation !== 'object') {
1631
+ return '(invalid)';
1632
+ }
1633
+ if (mutation.type === 'addElement') {
1634
+ return (mutation.element && mutation.element.id) || '(addElement)';
1635
+ }
1636
+ if (mutation.type === 'addRelationship') {
1637
+ return (mutation.relationship && mutation.relationship.id) || '(addRelationship)';
1638
+ }
1639
+ if (mutation.type === 'addView') {
1640
+ return (mutation.view && mutation.view.view_id) || '(addView)';
1641
+ }
1642
+ if (mutation.type === 'updateView') {
1643
+ return mutation.view_id || mutation.id || '(updateView)';
1644
+ }
1645
+ return mutation.id || mutation.view_id || `(${mutation.type})`;
1646
+ }
1647
+
1648
+ // Order a batch so it is order-independent: a mutation that references an object
1649
+ // created later in the SAME batch is applied after its producer. Dependencies:
1650
+ // addElement -> views it joins (view_ids)
1651
+ // addRelationship-> views it joins + its source/target elements
1652
+ // addView -> its parent element + included elements/relationships
1653
+ // update*/remove*-> the object they target (when that object is produced here)
1654
+ // A genuine cycle is reported with the id chain so the caller can split the batch.
1655
+ function orderMutationsForApplication(mutations) {
1656
+ const n = mutations.length;
1657
+ const producers = new Map();
1658
+ const addProducer = (key, index) => {
1659
+ if (!key) {
1660
+ return;
1661
+ }
1662
+ if (!producers.has(key)) {
1663
+ producers.set(key, []);
1664
+ }
1665
+ producers.get(key).push(index);
1666
+ };
1667
+
1668
+ mutations.forEach((mutation, index) => {
1669
+ if (!mutation || typeof mutation !== 'object') {
1670
+ return;
1671
+ }
1672
+ if (mutation.type === 'addElement' && mutation.element) {
1673
+ addProducer('element:' + mutation.element.id, index);
1674
+ }
1675
+ if (mutation.type === 'addRelationship' && mutation.relationship) {
1676
+ addProducer('relationship:' + mutation.relationship.id, index);
1677
+ }
1678
+ if (mutation.type === 'addView' && mutation.view) {
1679
+ addProducer('view:' + mutation.view.view_id, index);
1680
+ }
1681
+ });
1682
+
1683
+ const dependencies = mutations.map(() => new Set());
1684
+ mutations.forEach((mutation, index) => {
1685
+ if (!mutation || typeof mutation !== 'object') {
1686
+ return;
1687
+ }
1688
+ const requireKey = (key) => {
1689
+ const matches = producers.get(key);
1690
+ if (!matches) {
1691
+ return;
1692
+ }
1693
+ for (const producerIndex of matches) {
1694
+ if (producerIndex !== index) {
1695
+ dependencies[index].add(producerIndex);
1696
+ }
1697
+ }
1698
+ };
1699
+
1700
+ if (mutation.type === 'addElement') {
1701
+ for (const viewId of Array.isArray(mutation.view_ids) ? mutation.view_ids : []) {
1702
+ requireKey('view:' + viewId);
1703
+ }
1704
+ } else if (mutation.type === 'addRelationship') {
1705
+ for (const viewId of Array.isArray(mutation.view_ids) ? mutation.view_ids : []) {
1706
+ requireKey('view:' + viewId);
1707
+ }
1708
+ if (mutation.relationship) {
1709
+ requireKey('element:' + mutation.relationship.source_id);
1710
+ requireKey('element:' + mutation.relationship.target_id);
1711
+ }
1712
+ } else if (mutation.type === 'addView') {
1713
+ if (mutation.view) {
1714
+ requireKey('element:' + mutation.view.parent_element_id);
1715
+ for (const elementId of Array.isArray(mutation.view.included_elements) ? mutation.view.included_elements : []) {
1716
+ requireKey('element:' + elementId);
1717
+ }
1718
+ for (const relationshipId of Array.isArray(mutation.view.included_relationships) ? mutation.view.included_relationships : []) {
1719
+ requireKey('relationship:' + relationshipId);
1720
+ }
1721
+ }
1722
+ } else if (mutation.type === 'updateElement') {
1723
+ requireKey('element:' + mutation.id);
1724
+ } else if (mutation.type === 'updateRelationship') {
1725
+ requireKey('relationship:' + mutation.id);
1726
+ } else if (mutation.type === 'updateView') {
1727
+ requireKey('view:' + (mutation.view_id || mutation.id));
1728
+ } else if (mutation.type === 'removeView') {
1729
+ requireKey('view:' + mutation.view_id);
1730
+ } else if (mutation.type === 'removeElement') {
1731
+ requireKey('element:' + mutation.id);
1732
+ } else if (mutation.type === 'removeRelationship') {
1733
+ requireKey('relationship:' + mutation.id);
1734
+ }
1735
+ });
1736
+
1737
+ const indegree = mutations.map((_, index) => dependencies[index].size);
1738
+ const dependents = mutations.map(() => []);
1739
+ dependencies.forEach((set, index) => {
1740
+ for (const dependency of set) {
1741
+ dependents[dependency].push(index);
1742
+ }
1743
+ });
1744
+
1745
+ const remaining = new Set();
1746
+ for (let index = 0; index < n; index += 1) {
1747
+ remaining.add(index);
1748
+ }
1749
+ const ready = [];
1750
+ for (let index = 0; index < n; index += 1) {
1751
+ if (indegree[index] === 0) {
1752
+ ready.push(index);
1753
+ }
1754
+ }
1755
+ ready.sort((a, b) => a - b);
1756
+
1757
+ const ordered = [];
1758
+ while (ready.length > 0) {
1759
+ const index = ready.shift();
1760
+ if (!remaining.has(index)) {
1761
+ continue;
1762
+ }
1763
+ remaining.delete(index);
1764
+ ordered.push(mutations[index]);
1765
+ for (const dependent of dependents[index]) {
1766
+ indegree[dependent] -= 1;
1767
+ if (indegree[dependent] === 0) {
1768
+ ready.push(dependent);
1769
+ }
1770
+ }
1771
+ ready.sort((a, b) => a - b);
1772
+ }
1773
+
1774
+ if (ordered.length !== n) {
1775
+ const chain = findMutationCycle(mutations, dependencies, remaining);
1776
+ throw new Error(
1777
+ `Cyclic mutation dependencies in batch: ${chain.join(' -> ')}. `
1778
+ + 'Order the batch so each referenced object is created first, or split the mutually-dependent objects into separate calls.',
1779
+ );
1780
+ }
1781
+
1782
+ return ordered;
1783
+ }
1784
+
1785
+ function findMutationCycle(mutations, dependencies, remaining) {
1786
+ const visited = new Set();
1787
+ const inStack = new Set();
1788
+ const stack = [];
1789
+ let cycle = null;
1790
+
1791
+ const visit = (index) => {
1792
+ if (cycle) {
1793
+ return;
1794
+ }
1795
+ visited.add(index);
1796
+ inStack.add(index);
1797
+ stack.push(index);
1798
+ for (const dependency of dependencies[index]) {
1799
+ if (!remaining.has(dependency)) {
1800
+ continue;
1801
+ }
1802
+ if (inStack.has(dependency)) {
1803
+ cycle = stack.slice(stack.indexOf(dependency)).concat(dependency);
1804
+ return;
1805
+ }
1806
+ if (!visited.has(dependency)) {
1807
+ visit(dependency);
1808
+ }
1809
+ if (cycle) {
1810
+ return;
1811
+ }
1812
+ }
1813
+ inStack.delete(index);
1814
+ stack.pop();
1815
+ };
1816
+
1817
+ for (const index of remaining) {
1818
+ if (!visited.has(index)) {
1819
+ visit(index);
1820
+ }
1821
+ if (cycle) {
1822
+ break;
1823
+ }
1824
+ }
1825
+
1826
+ const chain = cycle || [...remaining];
1827
+ return chain.map((index) => mutationNodeId(mutations[index]));
1828
+ }
1829
+
1371
1830
  function applyMutations(document, mutations, options = {}) {
1372
1831
  const nextDocument = clone(document);
1373
1832
  const touchedElementIds = new Set();
@@ -1380,7 +1839,11 @@ function applyMutations(document, mutations, options = {}) {
1380
1839
  throw new Error('mutations must contain at least one mutation');
1381
1840
  }
1382
1841
 
1383
- for (const mutation of mutations) {
1842
+ // Apply in dependency order so a batch is order-independent (see
1843
+ // orderMutationsForApplication); a genuine cycle throws with the id chain.
1844
+ const orderedMutations = orderMutationsForApplication(mutations);
1845
+
1846
+ for (const mutation of orderedMutations) {
1384
1847
  if (!mutation || typeof mutation !== 'object' || !HANDLED_MUTATION_TYPES.has(mutation.type)) {
1385
1848
  throw new Error(`Unsupported mutation type: ${mutation && mutation.type}`);
1386
1849
  }
@@ -1388,11 +1851,30 @@ function applyMutations(document, mutations, options = {}) {
1388
1851
  if (mutation.type === 'addElement') {
1389
1852
  requireObject(mutation.element, 'mutation.element');
1390
1853
  const scopedViews = requireViewScope(nextDocument.views, mutation.view_ids, 'mutation.view_ids');
1391
- requireId(mutation.element.id, 'mutation.element.id');
1392
- const existingElement = findById(nextDocument.elements, mutation.element.id);
1393
- let targetElementId = mutation.element.id;
1394
- let reusedElement = false;
1395
- if (!existingElement) {
1854
+ const requestedElementId = typeof mutation.element.id === 'string' && mutation.element.id !== ''
1855
+ ? mutation.element.id
1856
+ : null;
1857
+ let targetElementId;
1858
+ let elementCreated = false;
1859
+ let elementReused = false;
1860
+
1861
+ const existingById = requestedElementId ? findById(nextDocument.elements, requestedElementId) : undefined;
1862
+ if (existingById) {
1863
+ // An explicit id that already exists: idempotent ONLY if it is the same
1864
+ // (type,name). Otherwise the caller picked a taken id — fail loudly instead
1865
+ // of silently attaching an unrelated element (issue #8 collision safety).
1866
+ const sameIdentity = existingById.type === mutation.element.type
1867
+ && normalizeDedupName(existingById.name) === normalizeDedupName(mutation.element.name);
1868
+ if (!sameIdentity) {
1869
+ throw new Error(
1870
+ `addElement id '${requestedElementId}' is already used by a different element `
1871
+ + `(type '${existingById.type}', name '${existingById.name}'). Choose another id (or omit id to auto-allocate one), `
1872
+ + `attach the existing element by using its id, or update it with updateArchitectureElement.`,
1873
+ );
1874
+ }
1875
+ targetElementId = requestedElementId;
1876
+ elementReused = true;
1877
+ } else {
1396
1878
  const candidates = findDuplicateElements(nextDocument.elements, mutation.element);
1397
1879
  const resolution = resolveDuplicateConflict({
1398
1880
  onConflict: mutation.onConflict,
@@ -1401,12 +1883,19 @@ function applyMutations(document, mutations, options = {}) {
1401
1883
  }, candidates);
1402
1884
  if (resolution.action === 'reuse') {
1403
1885
  targetElementId = resolution.existing.id;
1404
- reusedElement = true;
1886
+ elementReused = true;
1405
1887
  } else {
1406
- nextDocument.elements.push(clone(mutation.element));
1407
- syncViewsToElementSubdiagramViews(nextDocument, findById(nextDocument.elements, mutation.element.id));
1888
+ targetElementId = requestedElementId || allocateUniqueId(
1889
+ nextDocument.elements.map(entry => entry.id),
1890
+ mutation.element.name,
1891
+ slugifyId(mutation.element.type) || 'element',
1892
+ );
1893
+ nextDocument.elements.push({ ...clone(mutation.element), id: targetElementId });
1894
+ syncViewsToElementSubdiagramViews(nextDocument, findById(nextDocument.elements, targetElementId));
1895
+ elementCreated = true;
1408
1896
  }
1409
1897
  }
1898
+
1410
1899
  for (const view of scopedViews) {
1411
1900
  view.included_elements = addUnique(view.included_elements || [], [targetElementId]);
1412
1901
  touchedViewIds.add(view.view_id);
@@ -1417,8 +1906,10 @@ function applyMutations(document, mutations, options = {}) {
1417
1906
  type: mutation.type,
1418
1907
  id: targetElementId,
1419
1908
  view_ids: mutation.view_ids,
1420
- created: !existingElement && !reusedElement,
1421
- ...(reusedElement ? { reused: true, reusedId: targetElementId, requestedId: mutation.element.id } : {}),
1909
+ created: elementCreated,
1910
+ ...(elementReused ? { reused: true, reusedId: targetElementId } : {}),
1911
+ ...(requestedElementId === null && elementCreated ? { allocatedId: targetElementId } : {}),
1912
+ ...(elementReused && requestedElementId && requestedElementId !== targetElementId ? { requestedId: requestedElementId } : {}),
1422
1913
  });
1423
1914
  continue;
1424
1915
  }
@@ -1517,13 +2008,35 @@ function applyMutations(document, mutations, options = {}) {
1517
2008
  if (mutation.type === 'addRelationship') {
1518
2009
  requireObject(mutation.relationship, 'mutation.relationship');
1519
2010
  const scopedViews = requireViewScope(nextDocument.views, mutation.view_ids, 'mutation.view_ids');
1520
- requireId(mutation.relationship.id, 'mutation.relationship.id');
1521
- const existingRelationship = findById(nextDocument.relationships, mutation.relationship.id);
1522
- let targetRelationshipId = mutation.relationship.id;
2011
+ const requestedRelationshipId = typeof mutation.relationship.id === 'string' && mutation.relationship.id !== ''
2012
+ ? mutation.relationship.id
2013
+ : null;
2014
+ let targetRelationshipId;
1523
2015
  let sourceElementId = mutation.relationship.source_id;
1524
2016
  let targetEndpointId = mutation.relationship.target_id;
1525
- let reusedRelationship = false;
1526
- if (!existingRelationship) {
2017
+ let relationshipCreated = false;
2018
+ let relationshipReused = false;
2019
+
2020
+ const existingById = requestedRelationshipId ? findById(nextDocument.relationships, requestedRelationshipId) : undefined;
2021
+ if (existingById) {
2022
+ // Explicit id that already exists: idempotent only for the same natural key;
2023
+ // otherwise fail loudly instead of silently attaching an unrelated edge.
2024
+ const sameIdentity = existingById.source_id === mutation.relationship.source_id
2025
+ && existingById.type === mutation.relationship.type
2026
+ && existingById.target_id === mutation.relationship.target_id
2027
+ && normalizeDedupName(existingById.name) === normalizeDedupName(mutation.relationship.name);
2028
+ if (!sameIdentity) {
2029
+ throw new Error(
2030
+ `addRelationship id '${requestedRelationshipId}' is already used by a different relationship `
2031
+ + `(source '${existingById.source_id}', type '${existingById.type}', target '${existingById.target_id}'). `
2032
+ + 'Choose another id (or omit id to auto-allocate one), or update it with updateArchitectureRelationship.',
2033
+ );
2034
+ }
2035
+ targetRelationshipId = requestedRelationshipId;
2036
+ sourceElementId = existingById.source_id;
2037
+ targetEndpointId = existingById.target_id;
2038
+ relationshipReused = true;
2039
+ } else {
1527
2040
  const candidates = findDuplicateRelationships(nextDocument.relationships, mutation.relationship);
1528
2041
  const resolution = resolveDuplicateConflict({
1529
2042
  onConflict: mutation.onConflict,
@@ -1534,9 +2047,15 @@ function applyMutations(document, mutations, options = {}) {
1534
2047
  targetRelationshipId = resolution.existing.id;
1535
2048
  sourceElementId = resolution.existing.source_id;
1536
2049
  targetEndpointId = resolution.existing.target_id;
1537
- reusedRelationship = true;
2050
+ relationshipReused = true;
1538
2051
  } else {
1539
- nextDocument.relationships.push(clone(mutation.relationship));
2052
+ targetRelationshipId = requestedRelationshipId || allocateUniqueId(
2053
+ nextDocument.relationships.map(entry => entry.id),
2054
+ mutation.relationship.name || `${mutation.relationship.source_id}-${mutation.relationship.type}-${mutation.relationship.target_id}`,
2055
+ 'relationship',
2056
+ );
2057
+ nextDocument.relationships.push({ ...clone(mutation.relationship), id: targetRelationshipId });
2058
+ relationshipCreated = true;
1540
2059
  }
1541
2060
  }
1542
2061
  for (const view of scopedViews) {
@@ -1552,8 +2071,9 @@ function applyMutations(document, mutations, options = {}) {
1552
2071
  type: mutation.type,
1553
2072
  id: targetRelationshipId,
1554
2073
  view_ids: mutation.view_ids,
1555
- created: !existingRelationship && !reusedRelationship,
1556
- ...(reusedRelationship ? { reused: true, reusedId: targetRelationshipId, requestedId: mutation.relationship.id } : {}),
2074
+ created: relationshipCreated,
2075
+ ...(relationshipReused ? { reused: true, reusedId: targetRelationshipId } : {}),
2076
+ ...(requestedRelationshipId === null && relationshipCreated ? { allocatedId: targetRelationshipId } : {}),
1557
2077
  });
1558
2078
  continue;
1559
2079
  }
@@ -1637,38 +2157,65 @@ function applyMutations(document, mutations, options = {}) {
1637
2157
 
1638
2158
  if (mutation.type === 'addView') {
1639
2159
  requireObject(mutation.view, 'mutation.view');
1640
- if (findView(nextDocument.views, mutation.view.view_id)) {
1641
- throw new Error(`View '${mutation.view.view_id}' already exists`);
1642
- }
1643
- const candidates = findDuplicateViews(nextDocument.views, mutation.view);
1644
- const resolution = resolveDuplicateConflict({
1645
- onConflict: mutation.onConflict,
1646
- justification: mutation.justification,
1647
- label: `view (name '${mutation.view.view_name}')`,
1648
- }, candidates);
1649
- if (resolution.action === 'reuse') {
1650
- mutationSummaries.push({
1651
- type: mutation.type,
1652
- id: resolution.existing.view_id,
1653
- created: false,
1654
- reused: true,
1655
- reusedId: resolution.existing.view_id,
1656
- requestedId: mutation.view.view_id,
1657
- });
1658
- } else {
1659
- const newView = clone(mutation.view);
1660
- if (Array.isArray(newView.included_elements)) {
1661
- newView.included_elements = addUnique([], newView.included_elements);
2160
+ const requestedViewId = typeof mutation.view.view_id === 'string' && mutation.view.view_id !== ''
2161
+ ? mutation.view.view_id
2162
+ : null;
2163
+ let targetViewId;
2164
+ let viewCreated = false;
2165
+ let viewReused = false;
2166
+
2167
+ const existingById = requestedViewId ? findView(nextDocument.views, requestedViewId) : undefined;
2168
+ if (existingById) {
2169
+ // Explicit view_id that already exists: idempotent only for the same
2170
+ // (parent,name); otherwise fail loudly.
2171
+ const sameIdentity = (existingById.parent_element_id || '') === (mutation.view.parent_element_id || '')
2172
+ && normalizeDedupName(existingById.view_name) === normalizeDedupName(mutation.view.view_name);
2173
+ if (!sameIdentity) {
2174
+ throw new Error(
2175
+ `addView view_id '${requestedViewId}' is already used by a different view `
2176
+ + `(name '${existingById.view_name}', parent '${existingById.parent_element_id || ''}'). `
2177
+ + 'Choose another view_id (or omit it to auto-allocate one), or update it with updateArchitectureView.',
2178
+ );
1662
2179
  }
1663
- if (Array.isArray(newView.included_relationships)) {
1664
- newView.included_relationships = addUnique([], newView.included_relationships);
2180
+ targetViewId = requestedViewId;
2181
+ viewReused = true;
2182
+ } else {
2183
+ const candidates = findDuplicateViews(nextDocument.views, mutation.view);
2184
+ const resolution = resolveDuplicateConflict({
2185
+ onConflict: mutation.onConflict,
2186
+ justification: mutation.justification,
2187
+ label: `view (name '${mutation.view.view_name}')`,
2188
+ }, candidates);
2189
+ if (resolution.action === 'reuse') {
2190
+ targetViewId = resolution.existing.view_id;
2191
+ viewReused = true;
2192
+ } else {
2193
+ targetViewId = requestedViewId || allocateUniqueId(
2194
+ nextDocument.views.map(entry => entry.view_id),
2195
+ mutation.view.view_name,
2196
+ 'view',
2197
+ );
2198
+ const newView = { ...clone(mutation.view), view_id: targetViewId };
2199
+ if (Array.isArray(newView.included_elements)) {
2200
+ newView.included_elements = addUnique([], newView.included_elements);
2201
+ }
2202
+ if (Array.isArray(newView.included_relationships)) {
2203
+ newView.included_relationships = addUnique([], newView.included_relationships);
2204
+ }
2205
+ nextDocument.views.push(newView);
2206
+ upsertSubdiagramViewIntoElement(nextDocument, newView.parent_element_id, newView);
2207
+ touchedViewIds.add(newView.view_id);
2208
+ viewLimitCheckIds.add(newView.view_id);
2209
+ viewCreated = true;
1665
2210
  }
1666
- nextDocument.views.push(newView);
1667
- upsertSubdiagramViewIntoElement(nextDocument, newView.parent_element_id, newView);
1668
- touchedViewIds.add(newView.view_id);
1669
- viewLimitCheckIds.add(newView.view_id);
1670
- mutationSummaries.push({ type: mutation.type, id: newView.view_id });
1671
2211
  }
2212
+ mutationSummaries.push({
2213
+ type: mutation.type,
2214
+ id: targetViewId,
2215
+ created: viewCreated,
2216
+ ...(viewReused ? { reused: true, reusedId: targetViewId } : {}),
2217
+ ...(requestedViewId === null && viewCreated ? { allocatedId: targetViewId } : {}),
2218
+ });
1672
2219
  continue;
1673
2220
  }
1674
2221
 
@@ -1740,6 +2287,33 @@ function requireObject(value, label) {
1740
2287
  }
1741
2288
  }
1742
2289
 
2290
+ function slugifyId(seed) {
2291
+ return String(seed == null ? '' : seed)
2292
+ .toLowerCase()
2293
+ .replace(/[^a-z0-9]+/g, '-')
2294
+ .replace(/-{2,}/g, '-')
2295
+ .replace(/^-+|-+$/g, '');
2296
+ }
2297
+
2298
+ // One unified id strategy for the whole framework (issue #8): a semantic slug
2299
+ // derived from the entity's human name, made unique within its collection by a
2300
+ // numeric suffix. Callers MAY omit an id; the server allocates one. There is no
2301
+ // per-repository id strategy — ArchGraph has a single one.
2302
+ function allocateUniqueId(existingIds, seed, fallbackBase) {
2303
+ const taken = new Set((Array.isArray(existingIds) ? existingIds : []).map(String));
2304
+ const base = slugifyId(seed) || fallbackBase || 'item';
2305
+ if (!taken.has(base)) {
2306
+ return base;
2307
+ }
2308
+ for (let n = 2; n < 100000; n += 1) {
2309
+ const candidate = `${base}-${String(n).padStart(3, '0')}`;
2310
+ if (!taken.has(candidate)) {
2311
+ return candidate;
2312
+ }
2313
+ }
2314
+ return `${base}-${Date.now()}`;
2315
+ }
2316
+
1743
2317
  function requireId(value, label) {
1744
2318
  if (typeof value !== 'string' || value.length === 0) {
1745
2319
  throw new Error(`${label} must be a non-empty string`);
@@ -1930,7 +2504,7 @@ async function buildMutationResult(context, mutations, write, dependencies, loss
1930
2504
  before: beforeSummary,
1931
2505
  after: beforeSummary,
1932
2506
  errors,
1933
- guidance: buildFailureGuidance(errors),
2507
+ guidance: buildFailureGuidance(errors, context.ontology),
1934
2508
  };
1935
2509
  if (error && Array.isArray(error.duplicateConflicts) && error.duplicateConflicts.length > 0) {
1936
2510
  failed.duplicateConflicts = error.duplicateConflicts;
@@ -1941,6 +2515,7 @@ async function buildMutationResult(context, mutations, write, dependencies, loss
1941
2515
  return failed;
1942
2516
  }
1943
2517
  const errors = validateDocument(mutationResult.document, context.schema, {
2518
+ ontology: context.ontology,
1944
2519
  touchedRelationshipIds: mutationResult.touchedRelationshipIds,
1945
2520
  touchedViewIds: mutationResult.viewLimitCheckIds,
1946
2521
  });
@@ -1960,7 +2535,7 @@ async function buildMutationResult(context, mutations, write, dependencies, loss
1960
2535
  errors,
1961
2536
  };
1962
2537
  if (errors.length > 0) {
1963
- result.guidance = buildFailureGuidance(errors);
2538
+ result.guidance = buildFailureGuidance(errors, context.ontology);
1964
2539
  }
1965
2540
 
1966
2541
  // Semantic dedup gate: a would-be element create is blocked when a same-type
@@ -2215,10 +2790,10 @@ function buildMutationEmbeddingLifecycleFailure(error, result) {
2215
2790
  });
2216
2791
  }
2217
2792
 
2218
- function buildFailureGuidance(errors) {
2793
+ function buildFailureGuidance(errors, ontology) {
2219
2794
  const guidance = [];
2220
2795
  for (const error of errors || []) {
2221
- addGuidanceForError(guidance, String(error));
2796
+ addGuidanceForError(guidance, String(error), ontology);
2222
2797
  }
2223
2798
  if (guidance.length === 0 && Array.isArray(errors) && errors.length > 0) {
2224
2799
  guidance.push('Inspect the error text, call getSystemArchitecture with an explicit semantic query to refresh relevant ids, use getIntentElementContext for focused dependency context when needed, then retry with previewSystemArchitectureMutation before writing. Use an omitted-query full snapshot only when exact complete view membership is required.');
@@ -2226,15 +2801,17 @@ function buildFailureGuidance(errors) {
2226
2801
  return guidance;
2227
2802
  }
2228
2803
 
2229
- function addGuidanceForError(guidance, error) {
2804
+ function addGuidanceForError(guidance, error, ontology) {
2805
+ const language = ontology && ontology.language ? ontology.language : 'ArchiMate 3.2';
2806
+ const matrixLabel = ontology && ontology.matrixErrorLabel ? ontology.matrixErrorLabel : 'ArchiMate 3.2 relationship matrix';
2230
2807
  if (error.includes('mutation.view_ids must contain at least one view id')) {
2231
2808
  pushUnique(guidance, 'Select the target view_ids explicitly. Prefer getSystemArchitecture with an explicit semantic query to find relevant views, then use getIntentElementContext for focused element dependencies when needed. Use a full snapshot only if exact complete view membership is required.');
2232
2809
  }
2233
- if (error.includes('violates ArchiMate 3.2 relationship matrix')) {
2234
- pushUnique(guidance, 'Check relationship.type and the source and target element types against ArchiMate 3.2. If the intended meaning is still valid, choose a compliant relationship type or change the endpoint element types by remove-and-add.');
2810
+ if (error.includes(`violates ${matrixLabel}`)) {
2811
+ pushUnique(guidance, `Check relationship.type and the source and target element types against ${language}. If the intended meaning is still valid, choose a compliant relationship type or change the endpoint element types by remove-and-add.`);
2235
2812
  }
2236
- if (error.includes('uses unsupported ArchiMate relationship type')) {
2237
- pushUnique(guidance, 'Use relationship.type for the ArchiMate relationship type and choose one of the schema-supported ArchiMate 3.2 relationship types.');
2813
+ if (error.includes('uses unsupported') && error.includes('relationship type')) {
2814
+ pushUnique(guidance, `Use relationship.type for the relationship type and choose one of the schema-supported ${language} relationship types.`);
2238
2815
  }
2239
2816
  if (error.includes('id cannot be updated') || error.includes('type cannot be updated')) {
2240
2817
  pushUnique(guidance, 'Do not patch immutable identity or type fields. To change an id or type, remove the existing element or relationship, then add the replacement with the desired id or type.');
@@ -2243,10 +2820,16 @@ function addGuidanceForError(guidance, error) {
2243
2820
  pushUnique(guidance, 'Every element and relationship must belong to at least one view. Add it with view_ids, or add the existing object to an appropriate view before validating again.');
2244
2821
  }
2245
2822
  if (error.includes('must declare parent_element_id') || error.includes('top-level view')) {
2246
- pushUnique(guidance, 'Keep exactly one top-level view named SystemArchitecture. For any sub-view, set parent_element_id to an existing element and keep parent_element_name aligned with that element name.');
2823
+ const rootViewName = ontology && ontology.invariants && ontology.invariants.rootViewName
2824
+ ? ontology.invariants.rootViewName
2825
+ : 'SystemArchitecture';
2826
+ pushUnique(guidance, `Keep exactly one top-level view${rootViewName ? ` named ${rootViewName}` : ''}. For any sub-view, set parent_element_id to an existing element and keep parent_element_name aligned with that element name.`);
2247
2827
  }
2248
- if (error.includes('must contain at most 15 elements')) {
2249
- pushUnique(guidance, 'Do not force more than 15 included_elements into one view. Pause and think about layered architecture: split the view into layered sub-views, attach each sub-view with parent_element_id, and move lower-level elements into the appropriate child view before retrying.');
2828
+ if (error.includes('must contain at most') && error.includes('elements')) {
2829
+ const maxElements = ontology && ontology.invariants && ontology.invariants.maxElementsPerView !== undefined && ontology.invariants.maxElementsPerView !== null
2830
+ ? ontology.invariants.maxElementsPerView
2831
+ : 15;
2832
+ pushUnique(guidance, `Do not force more than ${maxElements} included_elements into one view. Pause and think about layered architecture: split the view into layered sub-views, attach each sub-view with parent_element_id, and move lower-level elements into the appropriate child view before retrying.`);
2250
2833
  }
2251
2834
  if (error.includes('does not exist') || error.includes('references missing')) {
2252
2835
  pushUnique(guidance, 'Refresh current ids with getSystemArchitecture semantic query first, then call getIntentElementContext for any returned element that needs dependency context. Do not guess ids; use existing element, relationship, and view ids or create missing objects first.');
@@ -2625,6 +3208,18 @@ function compactMutationResponse(payload) {
2625
3208
  status: payload && payload.status,
2626
3209
  written: Boolean(payload && payload.written),
2627
3210
  };
3211
+ // A successful write must tell the caller which id was used/allocated and whether
3212
+ // it created or reused — otherwise an omitted (auto-allocated) id is invisible
3213
+ // and an id collision would look like a create. Kept to a few fields (issue #8).
3214
+ if (Array.isArray(payload && payload.mutations) && payload.mutations.length > 0) {
3215
+ compact.mutations = payload.mutations.map((entry) => ({
3216
+ type: entry.type,
3217
+ id: entry.id,
3218
+ created: entry.created,
3219
+ ...(entry.reused ? { reused: true, reusedId: entry.reusedId } : {}),
3220
+ ...(entry.allocatedId ? { allocatedId: entry.allocatedId } : {}),
3221
+ }));
3222
+ }
2628
3223
  if (payload && payload.embeddingLifecycle && payload.embeddingLifecycle.state) {
2629
3224
  compact.embeddingLifecycle = { state: payload.embeddingLifecycle.state };
2630
3225
  }
@@ -2669,15 +3264,51 @@ function compactMutationResponse(payload) {
2669
3264
  return compact;
2670
3265
  }
2671
3266
 
2672
- function getSystemArchitectureResult(payload) {
2673
- const failed = payload.status === 'failed';
2674
- return toolResult(payload, {
3267
+ // The typed getSystemArchitecture output contract (see
3268
+ // GET_SYSTEM_ARCHITECTURE_OUTPUT_SCHEMA). The error variant allows only the
3269
+ // listed keys, so any error (local or mirrored from a cross-project read) is
3270
+ // projected onto that shape with category + message guaranteed. Exposed so the
3271
+ // argo MCP server can build the SAME structuredContent for a cross-project read
3272
+ // (which bypasses this module's callTool) instead of returning a result without
3273
+ // structuredContent — which violates the declared outputSchema (-32600).
3274
+ const GET_SYSTEM_ARCHITECTURE_ERROR_KEYS = Object.freeze([
3275
+ 'category', 'message', 'action', 'fullSnapshotFallback', 'state',
3276
+ 'canonicalVersion', 'contentVersion', 'indexVersion',
3277
+ 'completedChannels', 'missingChannels', 'mismatchedChannels',
3278
+ ]);
3279
+
3280
+ function normalizeGetSystemArchitectureError(error) {
3281
+ const source = error && typeof error === 'object' ? error : {};
3282
+ const normalized = {};
3283
+ for (const key of GET_SYSTEM_ARCHITECTURE_ERROR_KEYS) {
3284
+ if (Object.prototype.hasOwnProperty.call(source, key) && source[key] !== undefined) {
3285
+ normalized[key] = source[key];
3286
+ }
3287
+ }
3288
+ if (typeof normalized.category !== 'string' || normalized.category === '') {
3289
+ normalized.category = 'GET_SYSTEM_ARCHITECTURE_ERROR';
3290
+ }
3291
+ if (typeof normalized.message !== 'string' || normalized.message === '') {
3292
+ normalized.message = typeof source.reason === 'string' && source.reason !== ''
3293
+ ? source.reason
3294
+ : 'getSystemArchitecture failed';
3295
+ }
3296
+ return normalized;
3297
+ }
3298
+
3299
+ function buildGetSystemArchitectureStructuredContent(payload) {
3300
+ const failed = Boolean(payload) && payload.status === 'failed';
3301
+ return {
2675
3302
  version: '1.0',
2676
3303
  mode: failed ? 'error' : 'semantic-query',
2677
- document: failed ? null : (payload.document === undefined ? null : payload.document),
2678
- query: failed ? null : (payload.query || null),
2679
- error: failed ? payload.error : null,
2680
- });
3304
+ document: failed ? null : (payload && payload.document === undefined ? null : payload.document),
3305
+ query: failed ? null : ((payload && payload.query) || null),
3306
+ error: failed ? normalizeGetSystemArchitectureError(payload.error) : null,
3307
+ };
3308
+ }
3309
+
3310
+ function getSystemArchitectureResult(payload) {
3311
+ return toolResult(payload, buildGetSystemArchitectureStructuredContent(payload));
2681
3312
  }
2682
3313
 
2683
3314
  async function callTool(name, args = {}, dependencies = undefined) {
@@ -3102,13 +3733,25 @@ async function queryNeo4jGraphTool(args = {}) {
3102
3733
  function queryNeo4jGraphSchemaResult(architecturePath, workspaceRoot) {
3103
3734
  const schema = buildNeo4jGraphSchema(architecturePath);
3104
3735
  let typeEnums = {};
3736
+ let schemaBundleInfo = {};
3105
3737
  try {
3106
3738
  const resolvedRoot = workspaceRoot || resolveWorkspaceRoot({ architecturePath });
3107
- const schemaPath = resolveSchemaPath(resolvedRoot);
3108
- const jsonSchema = readJson(schemaPath.absolutePath, schemaPath.relativePath);
3739
+ const { bundle, ontology } = loadSchemaBundleAndOntology(resolvedRoot);
3740
+ const enums = resolveTypeEnums(bundle);
3109
3741
  typeEnums = {
3110
- archimateElementTypes: (jsonSchema.$defs.archimateElementType || {}).enum || [],
3111
- archimateRelationshipTypes: (jsonSchema.$defs.archimateRelationshipType || {}).enum || [],
3742
+ archimateElementTypes: enums.elementTypes,
3743
+ archimateRelationshipTypes: enums.relationshipTypes,
3744
+ schemaLanguage: (bundle.config && bundle.config.language) || null,
3745
+ schemaKind: bundle.kind,
3746
+ schemaDialect: ontology.dialect,
3747
+ actorElementType: ontology.actorElementType,
3748
+ bundleValidation: ontology.bundleValidation,
3749
+ };
3750
+ schemaBundleInfo = {
3751
+ schemaPath: bundle.schema.relativePath,
3752
+ schemaKind: bundle.kind,
3753
+ schemaDir: bundle.relativeDir,
3754
+ guidePath: bundle.guidePath ? bundle.guidePath.relativePath : null,
3112
3755
  };
3113
3756
  } catch (error) {
3114
3757
  typeEnums = {
@@ -3125,6 +3768,7 @@ function queryNeo4jGraphSchemaResult(architecturePath, workspaceRoot) {
3125
3768
  schema: {
3126
3769
  ...schema,
3127
3770
  ...typeEnums,
3771
+ ...schemaBundleInfo,
3128
3772
  },
3129
3773
  usage: {
3130
3774
  scopeGraph: 'MATCH (e:Element {graphKey: $graphKey}) ...',
@@ -4445,8 +5089,12 @@ if (require.main === module) {
4445
5089
 
4446
5090
  module.exports = {
4447
5091
  GET_SYSTEM_ARCHITECTURE_OUTPUT_SCHEMA,
5092
+ buildGetSystemArchitectureStructuredContent,
4448
5093
  TOOLS,
4449
5094
  applyMutations,
5095
+ applyContextBudget,
5096
+ applyViewContextBudget,
5097
+ resolveContextMaxBytes,
4450
5098
  buildBusinessSemanticSummary,
4451
5099
  buildSemanticDedupAdvisory,
4452
5100
  selectCreatedElementAdds,