graphlin 0.2.0 → 0.2.1

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.
@@ -0,0 +1,209 @@
1
+ const MAX_PENDING = 10_000;
2
+ const BATCH_SIZE = 6;
3
+ const STATES = new Set(['waiting', 'queued', 'running', 'complete', 'partial', 'unavailable']);
4
+ const version = value => `${value.generation}:${value.hash}:${value.status}`;
5
+ const metadata = value => ({
6
+ artifactId: value.id, generation: value.generation, hash: value.hash, status: value.status,
7
+ });
8
+
9
+ /** Coalesces observations. Captured source lives only for the active analysis job. */
10
+ export function createArchitectureController({
11
+ snapshot, capture, commit, analyze, available = () => null, ready = () => true,
12
+ onChange = () => {}, onDiagnostic = () => {}, now = Date.now, settleMs = 300,
13
+ }) {
14
+ const known = new Map(), pending = new Map(), completed = new Map();
15
+ const retries = new Map();
16
+ let state = 'waiting', reason = 'no_source', timer, active, abort, activeVersions;
17
+ let closed = false, epoch = 0, manual = false, omitted = 0, failures = 0, lastRunAt = null;
18
+
19
+ function status() {
20
+ const model = snapshot();
21
+ const supported = (model.interpretations ?? []).filter(value =>
22
+ value.namespace === 'graphlin.architecture' && value.validity === 'current' &&
23
+ value.support === 'supported' && value.classification === 'accepted');
24
+ const blocked = available();
25
+ return {
26
+ status: blocked ? 'unavailable' : state,
27
+ ...(blocked || reason ? { reason: blocked || reason } : {}),
28
+ applications: supported.filter(value => value.kind === 'application').length,
29
+ components: supported.filter(value => value.kind === 'component').length,
30
+ pending: pending.size, inspected: completed.size, total: known.size,
31
+ omitted, failures, ...(lastRunAt === null ? {} : { lastRunAt }),
32
+ };
33
+ }
34
+ function publish(next, why = null) {
35
+ state = STATES.has(next) ? next : 'unavailable';
36
+ reason = why;
37
+ try { onChange(); } catch { /* Observation must remain fail-open. */ }
38
+ }
39
+ function diagnostic(result) {
40
+ try {
41
+ onDiagnostic({
42
+ status: result.status, code: result.diagnostics?.code ?? 'architecture_unavailable',
43
+ analyzed: result.coverage?.analyzedArtifactIds?.length ?? 0,
44
+ deferred: result.coverage?.deferredArtifactIds?.length ?? 0,
45
+ providerRequests: result.diagnostics?.providerRequests ?? 0,
46
+ });
47
+ } catch { /* Diagnostics cannot prevent admission or shutdown. */ }
48
+ }
49
+ function enqueue(id, { force = false } = {}) {
50
+ const item = known.get(id);
51
+ if (!item || (!force && completed.get(id) === version(item))) return;
52
+ if (!pending.has(id) && pending.size >= MAX_PENDING) { omitted++; return; }
53
+ pending.set(id, item);
54
+ }
55
+ function wake() {
56
+ if (closed) return;
57
+ const blocked = available();
58
+ if (blocked) {
59
+ clearTimeout(timer); timer = null;
60
+ abort?.abort();
61
+ return;
62
+ }
63
+ if (active || timer || (!pending.size && !manual)) return;
64
+ timer = setTimeout(() => {
65
+ timer = null;
66
+ if (!ready()) { wake(); return; }
67
+ void run();
68
+ }, settleMs);
69
+ timer.unref?.();
70
+ }
71
+ function observe(artifacts) {
72
+ if (closed) return;
73
+ for (const artifact of artifacts) {
74
+ const item = metadata(artifact), old = known.get(artifact.id);
75
+ if (old && version(old) === version(item)) continue;
76
+ if (!old && known.size >= MAX_PENDING) { omitted++; continue; }
77
+ known.set(artifact.id, item);
78
+ completed.delete(artifact.id);
79
+ retries.delete(artifact.id);
80
+ enqueue(artifact.id);
81
+ if (activeVersions?.has(artifact.id) && activeVersions.get(artifact.id) !== version(item)) abort?.abort();
82
+ }
83
+ if (!active && pending.size) { state = 'queued'; reason = null; }
84
+ wake();
85
+ }
86
+ async function run() {
87
+ if (active || closed || available()) return active;
88
+ clearTimeout(timer); timer = null;
89
+ if (manual) {
90
+ retries.clear();
91
+ completed.clear();
92
+ omitted = 0;
93
+ for (const id of known.keys()) enqueue(id, { force: true });
94
+ manual = false;
95
+ }
96
+ if (!pending.size) { publish('waiting', 'no_source'); return; }
97
+ const ids = [...pending.keys()].slice(0, BATCH_SIZE), generation = epoch;
98
+ abort = new AbortController();
99
+ const signal = abort.signal;
100
+ publish('running');
101
+ active = (async () => {
102
+ const artifacts = await capture(ids);
103
+ if (closed || signal.aborted || generation !== epoch) return;
104
+ const captured = new Map(artifacts.map(value => [value.id, metadata(value)]));
105
+ activeVersions = new Map(ids.flatMap(id => known.has(id)
106
+ ? [[id, version(captured.get(id) ?? known.get(id))]] : []));
107
+ // Parsing can settle after a newer capture was observed. Never mark that
108
+ // newer version inspected using an older capture.
109
+ if (ids.some(id => activeVersions.get(id) !== version(known.get(id) ?? {}))) return;
110
+ const result = await analyze({ model: snapshot(), artifacts, affectedArtifactIds: ids, signal });
111
+ if (closed || signal.aborted || generation !== epoch) return;
112
+ diagnostic(result);
113
+ const applied = result.affectedEntityIds?.length || result.coverage?.missingArtifactIds?.length
114
+ ? await commit(result, { artifacts: artifacts.map(metadata), signal, epoch: generation })
115
+ : true;
116
+ if (closed || signal.aborted || generation !== epoch) return;
117
+ if (!applied || applied.accepted === false) {
118
+ // A source race may be retried, but a persistently rejected admission
119
+ // must not become an endless background classification loop.
120
+ for (const id of ids) {
121
+ if (activeVersions.get(id) !== version(known.get(id) ?? {})) continue;
122
+ const count = (retries.get(id) ?? 0) + 1;
123
+ retries.set(id, count);
124
+ if (count >= 2) { pending.delete(id); completed.set(id, activeVersions.get(id)); failures++; }
125
+ }
126
+ publish(pending.size ? 'queued' : 'partial', 'source_changed');
127
+ return;
128
+ }
129
+ if (Number.isSafeInteger(applied.omitted) && applied.omitted > 0) omitted += applied.omitted;
130
+ const deferred = new Set(result.coverage?.deferredArtifactIds ?? []);
131
+ const progressed = ids.some(id => !deferred.has(id));
132
+ for (const id of ids) {
133
+ if (activeVersions.get(id) !== version(known.get(id) ?? {})) continue;
134
+ pending.delete(id);
135
+ // Move budget-deferred captures to the back, so the next batch can
136
+ // spend its source budget on them. Stop if a whole batch made no
137
+ // progress (for example, no capture is available).
138
+ if (deferred.has(id) && progressed) continue;
139
+ completed.set(id, activeVersions.get(id));
140
+ if (deferred.has(id)) failures++;
141
+ }
142
+ for (const id of deferred) enqueue(id);
143
+ for (const id of result.coverage?.deferredMembershipArtifactIds ?? []) {
144
+ const count = retries.get(id) ?? 0;
145
+ if (count < 2) { retries.set(id, count + 1); enqueue(id, { force: true }); }
146
+ else if (count === 2) { retries.set(id, 3); omitted++; }
147
+ }
148
+ lastRunAt = now();
149
+ if (result.status === 'unavailable') { failures++; publish('unavailable', 'analysis_failed'); }
150
+ else if (result.status === 'partial' || omitted) publish('partial', 'partial_coverage');
151
+ else {
152
+ const totals = status();
153
+ publish('complete', totals.applications + totals.components ? null : 'none_supported');
154
+ }
155
+ })().catch(() => {
156
+ if (!closed && !signal.aborted) {
157
+ failures++;
158
+ for (const id of ids) {
159
+ if (activeVersions && activeVersions.get(id) !== version(known.get(id) ?? {})) continue;
160
+ pending.delete(id);
161
+ if (known.has(id)) completed.set(id, version(known.get(id)));
162
+ }
163
+ publish('unavailable', 'analysis_failed');
164
+ }
165
+ }).finally(() => {
166
+ active = null; abort = null; activeVersions = null;
167
+ if (!closed) {
168
+ if (pending.size || manual) { state = 'queued'; wake(); }
169
+ else if (state === 'running') publish(available() ? 'unavailable' : 'partial', available() || 'source_changed');
170
+ }
171
+ });
172
+ return active;
173
+ }
174
+ function request() {
175
+ if (!closed) {
176
+ manual = true;
177
+ publish('queued', available());
178
+ wake();
179
+ }
180
+ return status();
181
+ }
182
+ function invalidate() {
183
+ epoch++;
184
+ abort?.abort();
185
+ completed.clear();
186
+ retries.clear();
187
+ for (const id of known.keys()) enqueue(id, { force: true });
188
+ wake();
189
+ }
190
+ return {
191
+ observe, wake, request, status, invalidate,
192
+ async whenIdle() {
193
+ clearTimeout(timer); timer = null;
194
+ while (!closed && !available() && (active || pending.size || manual)) {
195
+ if (!ready()) break;
196
+ await (active ?? run());
197
+ }
198
+ clearTimeout(timer); timer = null;
199
+ },
200
+ async close() {
201
+ closed = true;
202
+ clearTimeout(timer); timer = null;
203
+ abort?.abort();
204
+ pending.clear();
205
+ await active;
206
+ known.clear(); completed.clear(); retries.clear();
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,108 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { LIMITS, hash, isHash, isId, integer, opaque } from '../core/common.mjs';
3
+ import { createPolicy, metadataEvent, safeLabel, safeText } from '../core/privacy.mjs';
4
+ import { buildCandidates } from '../core/candidates.mjs';
5
+ import { id, references, relativePath } from '../model/records.mjs';
6
+ import { ARCHITECTURE_NAMESPACE } from './profile.mjs';
7
+
8
+ export const ARCHITECTURE_LIMITS = Object.freeze({
9
+ artifacts: 6, candidatesPerArtifact: 8, membershipChecks: 6,
10
+ sourceRefs: 16, guardRefs: 128, sourceBytes: 512 * 1024,
11
+ });
12
+ export const requireValue = value => { if (!value) throw new Error('architecture_input_invalid'); };
13
+ export const lineageOf = model => model.coverage?.lineage?.id ?? model.projectId;
14
+ export const recordId = (projectId, ...parts) => opaque('architecture', projectId, ...parts);
15
+
16
+ export function indexModel(model, policy) {
17
+ requireValue(model?.schemaVersion === 2 && id(model.projectId) && integer(model.revision)
18
+ && !model.replay && model.checkpointId === undefined && id(lineageOf(model))
19
+ && Array.isArray(model.entities) && model.entities.length <= 20_000
20
+ && Array.isArray(model.relations) && model.relations.length <= 40_000
21
+ && Array.isArray(model.interpretations) && model.interpretations.length <= 512
22
+ && Array.isArray(model.coverage?.artifacts) && model.coverage.artifacts.length <= 10_000);
23
+ const entities = new Map(), artifacts = new Map(), byArtifact = new Map();
24
+ for (const artifact of model.coverage.artifacts) {
25
+ requireValue(id(artifact?.id) && !artifacts.has(artifact.id));
26
+ artifacts.set(artifact.id, artifact);
27
+ }
28
+ function currentRefs(value, max = ARCHITECTURE_LIMITS.sourceRefs) {
29
+ const refs = references(value, max);
30
+ if (!refs?.length || !isDeepStrictEqual(refs, value)) return null;
31
+ return refs.every(ref => {
32
+ const artifact = artifacts.get(ref.artifactId);
33
+ return ref.sourceClass !== 'public_intent' && artifact?.status === 'present'
34
+ && artifact.fresh === true && artifact.hash === ref.hash && artifact.generation === ref.generation
35
+ && relativePath(artifact.relativePath, policy);
36
+ }) ? refs : null;
37
+ }
38
+ for (const entity of model.entities) {
39
+ requireValue(id(entity?.id) && !entities.has(entity.id));
40
+ entities.set(entity.id, entity);
41
+ if (!byArtifact.has(entity.artifactId)) byArtifact.set(entity.artifactId, []);
42
+ if (entity.basis === 'parsed' && entity.validity === 'current' && entity.classification === 'accepted'
43
+ && safeLabel(entity.label) && currentRefs(entity.sourceRefs)) byArtifact.get(entity.artifactId).push(entity);
44
+ }
45
+ const relations = model.relations.filter(value => value?.validity === 'current'
46
+ && value.basis === 'parsed' && entities.has(value.source) && entities.has(value.target)
47
+ && ['contains', 'imports', 'calls', 'depends_on'].includes(value.kind) && currentRefs(value.sourceRefs));
48
+ return { entities, artifacts, byArtifact, relations, currentRefs };
49
+ }
50
+
51
+ export function moduleForArtifact(index, artifactId) {
52
+ const modules = (index.byArtifact.get(artifactId) ?? []).filter(entity => ['module', 'file'].includes(entity.kind));
53
+ return modules.length === 1 ? modules[0] : null;
54
+ }
55
+
56
+ export function requestedArtifacts(model, index, captures, affectedArtifactIds) {
57
+ const supplied = captures.map(value => value?.id);
58
+ requireValue(supplied.every(isId) && new Set(supplied).size === supplied.length);
59
+ if (affectedArtifactIds === undefined) return supplied;
60
+ requireValue(Array.isArray(affectedArtifactIds) && affectedArtifactIds.length <= 128
61
+ && affectedArtifactIds.every(isId) && new Set(affectedArtifactIds).size === affectedArtifactIds.length);
62
+ const seeds = new Set(affectedArtifactIds), requested = new Set(seeds);
63
+ // One-hop dependencies nominate source for reconsideration, never membership.
64
+ for (const relation of index.relations) {
65
+ const source = index.entities.get(relation.source).artifactId;
66
+ const target = index.entities.get(relation.target).artifactId;
67
+ if (seeds.has(source) && isId(target)) requested.add(target);
68
+ if (seeds.has(target) && isId(source)) requested.add(source);
69
+ }
70
+ // Existing groups can depend on a changed file even if an import disappeared.
71
+ for (const value of model.interpretations) {
72
+ if (value.namespace !== ARCHITECTURE_NAMESPACE || !Array.isArray(value.sourceRefs)
73
+ || value.sourceRefs.length > ARCHITECTURE_LIMITS.sourceRefs
74
+ || !value.sourceRefs.some(ref => seeds.has(ref.artifactId))) continue;
75
+ for (const ref of value.sourceRefs) if (isId(ref.artifactId)) requested.add(ref.artifactId);
76
+ }
77
+ return [...seeds, ...[...requested].filter(value => !seeds.has(value)).sort()];
78
+ }
79
+
80
+ export function candidatesForCapture(capture, index, event, policy) {
81
+ const current = index.artifacts.get(capture.id);
82
+ const anchor = moduleForArtifact(index, capture.id);
83
+ if (!anchor || !current || current.status !== 'present' || current.fresh !== true
84
+ || capture.status !== 'present' || capture.exists !== true || capture.complete !== true
85
+ || !isHash(capture.hash) || !integer(capture.generation, 1)
86
+ || current.hash !== capture.hash || current.generation !== capture.generation
87
+ || !relativePath(capture.relativePath, policy) || current.relativePath !== capture.relativePath
88
+ || typeof capture.text !== 'string' || Buffer.byteLength(capture.text) > LIMITS.fileBytes
89
+ || !safeText(capture.text, LIMITS.fileBytes) || hash(capture.text) !== capture.hash) return null;
90
+ const candidates = buildCandidates({ event, artifacts: [capture], policy });
91
+ return { anchor, candidates: candidates.slice(0, ARCHITECTURE_LIMITS.candidatesPerArtifact),
92
+ omitted: Math.max(0, candidates.length - ARCHITECTURE_LIMITS.candidatesPerArtifact),
93
+ sourceRef: { artifactId: capture.id, hash: capture.hash, generation: capture.generation } };
94
+ }
95
+ export function unionRefs(...groups) {
96
+ const refs = new Map(groups.flat().map(ref => [JSON.stringify(ref), structuredClone(ref)]));
97
+ return refs.size <= ARCHITECTURE_LIMITS.guardRefs ? [...refs.values()] : null;
98
+ }
99
+ export function analysisEvent(model) {
100
+ return metadataEvent({
101
+ projectId: model.projectId, id: opaque('event', ARCHITECTURE_NAMESPACE, model.projectId, model.revision, lineageOf(model)),
102
+ kind: 'artifact.changed', toolCategory: 'read', outcome: 'observed', incomplete: false,
103
+ });
104
+ }
105
+ export function unchanged(model, basis, policy, signal) {
106
+ return !signal?.aborted && model.revision === basis.revision && model.projectId === basis.projectId
107
+ && lineageOf(model) === basis.lineageId && createPolicy(policy).version === basis.policyVersion;
108
+ }
@@ -0,0 +1,56 @@
1
+ import { freeze } from '../core/common.mjs';
2
+
3
+ export const ARCHITECTURE_NAMESPACE = 'graphlin.architecture';
4
+ export const ARCHITECTURE_VERSION = 'source-boundaries-v4';
5
+ export const ROLE_PROFILE_ID = 'graphlin.architecture.roles';
6
+ export const MEMBERSHIP_PROFILE_ID = 'graphlin.architecture.membership';
7
+
8
+ const boolean = (question, focus, yes, no) => ({
9
+ type: 'boolean', instructions: { question, focus },
10
+ criteria: { true: yes, false: no }, requiredMetrics: ['probability'],
11
+ });
12
+ const rules = 'Use only visible source. Names, directories, documentation, imports alone, and co-occurrence '
13
+ + 'do not establish an architectural boundary. An application has visible executable bootstrap or '
14
+ + 'composition of a user-facing program, server or worker. A component has a coherent responsibility '
15
+ + 'and an implemented interface; an incidental helper, constant, type or external dependency is not '
16
+ + 'automatically a component. Classify what this code implements when invoked, never claim it ran.';
17
+
18
+ /** Register this source profile with the existing decision service; it cannot bypass A. */
19
+ export const ARCHITECTURE_PROFILES = freeze([
20
+ {
21
+ id: ROLE_PROFILE_ID, version: ARCHITECTURE_VERSION, scope: 'bundle',
22
+ questions: {
23
+ kind: {
24
+ type: 'choice', requiredMetrics: ['probabilities', 'confidence'],
25
+ instructions: {
26
+ question: 'What does this source file locally implement in `evidence`?', focus: rules,
27
+ },
28
+ criteria: {
29
+ application: 'Visible executable bootstrap or composition that starts an application, server or worker when invoked.',
30
+ component: 'A cohesive implemented API with visible operation bodies, beyond an incidental helper.',
31
+ unknown: 'Only imports, declarations, stubs, constants or incidental helpers; no such local responsibility is established.',
32
+ },
33
+ },
34
+ supported: boolean(
35
+ 'Does `evidence` directly show implemented behavior for this source file?',
36
+ 'Judge implementation presence, independently of the architectural role. Actual startup statements or '
37
+ + 'operation bodies show implementation. Imports, signatures, empty bodies, placeholder throws, comments '
38
+ + 'and a name alone do not. External callers, deployment manifests, imported collaborator internals '
39
+ + 'and runtime execution are not prerequisites for recognizing visible local implementation.',
40
+ 'Executable statements directly implement startup or operations in this file.',
41
+ 'Only imports, declarations, scalar constants, empty bodies or placeholders are visible.',
42
+ ),
43
+ missing_context: boolean(
44
+ 'Is a local statement or definition absent from `evidence` that prevents identifying what this file implements?',
45
+ 'This is only about classifying the visible file as application, component or unknown. '
46
+ + 'Missing context requires an omitted/truncated body or unresolved local operation that prevents that decision. '
47
+ + 'A visible server construction and listen call suffice for application startup. Visible class or function '
48
+ + 'operation bodies can establish a component. Standard library internals, implementation of imported collaborators, '
49
+ + 'upstream callers, deployment configuration and runtime execution are not required to identify the file’s own role. '
50
+ + 'A fully visible constant or helper can conclusively be unknown without missing context.',
51
+ 'A necessary local body or statement is absent, so the file’s implemented responsibility cannot be identified.',
52
+ 'Visible code suffices to support or reject an application/component role for this file.',
53
+ ),
54
+ },
55
+ },
56
+ ]);
@@ -19,6 +19,7 @@ import { exportSnapshot } from './export.mjs';
19
19
  import { createDiagnostics } from './diagnostics.mjs';
20
20
  import { createDashboardInfoProvider } from './dashboard-info.mjs';
21
21
  import { createLineageReader } from './lineage.mjs';
22
+ import { ARCHITECTURE_PROFILES } from '../architecture/profile.mjs';
22
23
 
23
24
  const WEB = new URL('../web/', import.meta.url);
24
25
  const assets = new Map([
@@ -164,11 +165,11 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
164
165
  // Test/demo services are injected; this module never logs request bodies.
165
166
  const service = decisionService ?? createDecisionService({
166
167
  provider: decisionProvider ?? createJevProvider({ apiKey }),
167
- materializeBundle, buildRelationProposals,
168
+ materializeBundle, buildRelationProposals, profiles: ARCHITECTURE_PROFILES,
168
169
  limits: { eventDeadlineMs: 5000 },
169
170
  });
170
171
  pipeline = createPipeline({ projectRoot: paths.projectRoot, policy, decisionService: service,
171
- classificationDeadlineMs: 5000,
172
+ classificationDeadlineMs: 5000, missingKey,
172
173
  mode, restoredState: await persistence.load(), restoredModel: await modelPersistence.load(),
173
174
  onChange: notify, onDiagnostic: diagnostics.record });
174
175
  modelAPI = createModelAPI({ projectId: paths.projectId, getSnapshot: pipeline.getModelState,
@@ -218,6 +219,14 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
218
219
  return;
219
220
  }
220
221
  if (!auth.authorized(req)) return json(res, 401, { error: 'authentication_required' });
222
+ if (req.method === 'GET' && req.url === '/api/architecture') {
223
+ return json(res, 200, pipeline.getArchitectureStatus());
224
+ }
225
+ if (req.method === 'POST' && req.url === '/api/architecture/discover') {
226
+ const input = await bodyJSON(req);
227
+ if (Object.keys(input).length) return json(res, 400, { error: 'invalid_input' });
228
+ return json(res, 202, pipeline.discoverArchitecture());
229
+ }
221
230
  if (req.method === 'GET' && req.url === '/api/about') {
222
231
  try { return json(res, 200, await dashboardInfo()); }
223
232
  catch { return json(res, 503, { error: 'dashboard_info_unavailable' }); }
@@ -3,7 +3,7 @@ import { isDeepStrictEqual as equal } from 'node:util';
3
3
  import { integer, plain } from '../core/common.mjs';
4
4
  import {
5
5
  DEFAULT_LIMITS, id, token, label, key, byteSize, relativePath, currentPolicy,
6
- references, classification, entityRecord, relationRecord, interpretationRecord, certificate,
6
+ references, classification, entityRecord, relationRecord, interpretationRecord, interpretationNamespace, certificate,
7
7
  activityRecord, lineageRecord, projectSnapshot, time,
8
8
  } from './records.mjs';
9
9
  import { compareCheckpoint } from './changes.mjs';
@@ -159,6 +159,31 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
159
159
  if (!accepted) deferred.relations++;
160
160
  return accepted;
161
161
  }
162
+ function admitInterpretation(record) {
163
+ const old = interpretations.get(record.id), victims = [];
164
+ const delta = byteSize(record) - (weights.get(old) ?? 0);
165
+ let reclaimed = 0;
166
+ const fits = () => interpretations.size + (old ? 0 : 1) - victims.length <= limits.interpretations &&
167
+ bytes + historyBytes + delta - reclaimed <= limits.bytes;
168
+ if (record.namespace === 'graphlin.architecture') {
169
+ // Make room only when requested. Map order preserves first admission, so
170
+ // legacy eviction is deterministic and never displaces other boundaries.
171
+ for (const candidate of interpretations.values()) {
172
+ if (fits()) break;
173
+ if (candidate.namespace !== 'graphlin.legacy-role' || candidate.id === record.id) continue;
174
+ victims.push(candidate.id);
175
+ reclaimed += weights.get(candidate) ?? 0;
176
+ }
177
+ }
178
+ if (!fits()) { deferred.interpretations++; return false; }
179
+ for (const victim of victims) {
180
+ remove(interpretations, victim, 'interpretations');
181
+ deferred.interpretations++;
182
+ }
183
+ const accepted = put(interpretations, record.id, record, limits.interpretations, 'interpretations');
184
+ if (!accepted) deferred.interpretations++;
185
+ return accepted;
186
+ }
162
187
  function appendActivity(event, nextSequence = sequence) {
163
188
  const record = activityRecord(event, nextSequence, clock());
164
189
  if (!record) return;
@@ -534,21 +559,129 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
534
559
  }
535
560
  }
536
561
 
562
+ function currentInterpretationRefs(refs) {
563
+ if (!currentRefs(refs)) return false;
564
+ const effective = currentPolicy(policy);
565
+ return refs.every(ref => {
566
+ const artifact = artifacts.get(ref.artifactId), cert = artifact.enumeration;
567
+ return (!artifact.relativePath || relativePath(artifact.relativePath, effective)) &&
568
+ (!ref.extractor || ref.extractor === cert?.extractor) &&
569
+ (!ref.extractorVersion || ref.extractorVersion === cert?.version) &&
570
+ (!ref.identityVersion || ref.identityVersion === cert?.identityVersion);
571
+ });
572
+ }
573
+ function prepareInterpretation(value) {
574
+ const record = interpretationRecord(value, limits.refs);
575
+ if (!record || record.entityIds.some(entityId => !entities.has(entityId))) return null;
576
+ const current = currentInterpretationRefs(record.sourceRefs) &&
577
+ record.entityIds.every(entityId => entities.get(entityId).validity === 'current');
578
+ if (record.support === 'supported' && !current) return null;
579
+ const storageId = interpretations.get(record.id)?.namespace === record.namespace
580
+ ? record.id : key('interpretation', projectId, record.namespace, record.id);
581
+ return { ...record, id: storageId, ...(record.sourceRefs.length && !current
582
+ ? { validity: 'stale', classification: 'stale' } : {}) };
583
+ }
584
+
537
585
  function observeInterpretations(values, { event } = {}) {
538
586
  if (!Array.isArray(values) || !currentPolicy(policy).readSource) return stats();
587
+ const before = mutations, oldDeferred = deferred.interpretations;
539
588
  for (const value of values.slice(0, limits.interpretations)) {
540
- const record = interpretationRecord(value, limits.refs);
541
- if (!record || record.entityIds.some(entityId => !entities.has(entityId))) continue;
542
- if (record.support === 'supported' && (!currentRefs(record.sourceRefs) ||
543
- record.entityIds.some(entityId => entities.get(entityId).validity !== 'current'))) continue;
544
- const storageId = key('interpretation', projectId, record.namespace, record.id);
545
- const normalized = { ...record, id: storageId, validity: record.sourceRefs.length && !currentRefs(record.sourceRefs) ? 'stale' : record.validity };
546
- if (!put(interpretations, storageId, normalized, limits.interpretations, 'interpretations')) deferred.interpretations++;
547
- }
548
- commit('interpretations.observed', { sessionId: event?.sessionId });
589
+ const record = prepareInterpretation(value);
590
+ if (record) admitInterpretation(record);
591
+ }
592
+ if (mutations !== before || oldDeferred !== deferred.interpretations) {
593
+ commit('interpretations.observed', { sessionId: event?.sessionId });
594
+ }
549
595
  return stats();
550
596
  }
551
597
 
598
+ /**
599
+ * Replace a namespace, or only records touching the supplied entity/artifact
600
+ * scope. An explicit empty scope matches nothing; [] withdraws old boundaries.
601
+ * Batch guards allow up to 256 refs; each stored record still allows 16.
602
+ * Async callers should supply exact sourceRefs even for an empty answer.
603
+ * For confirmed missing artifacts, omit present-source refs only after the
604
+ * caller's serialized epoch/version check; an unguarded clear carries no version.
605
+ */
606
+ function replaceInterpretations(namespace, values, options = {}) {
607
+ const result = (accepted, changed = false, removed = 0, retained = 0) => ({
608
+ ...stats(), accepted, changed, removed, retained,
609
+ });
610
+ if (!interpretationNamespace(namespace) || !Array.isArray(values) || values.length > DEFAULT_LIMITS.interpretations ||
611
+ !plain(options) || !currentPolicy(policy).readSource) return result(false);
612
+ const scoped = options.affectedEntityIds !== undefined || options.artifactIds !== undefined;
613
+ const validIds = (values, limit) => Array.isArray(values) && values.length <= limit && values.every(value => id(value));
614
+ if (!validIds(options.affectedEntityIds ?? [], limits.entities) ||
615
+ !validIds(options.artifactIds ?? [], limits.artifacts)) return result(false);
616
+ const affected = new Set(options.affectedEntityIds ?? []), affectedArtifacts = new Set(options.artifactIds ?? []);
617
+ const matches = record => !scoped || record.entityIds.some(entityId =>
618
+ affected.has(entityId) || affectedArtifacts.has(entities.get(entityId)?.artifactId)) ||
619
+ record.sourceRefs.some(ref => affectedArtifacts.has(ref.artifactId));
620
+ if (options.sourceRefs !== undefined) {
621
+ const guards = references(options.sourceRefs, 256);
622
+ if (!guards || !currentInterpretationRefs(guards)) return result(false);
623
+ const guarded = new Set(guards.map(ref => ref.artifactId));
624
+ if ([...affectedArtifacts].some(artifactId => !guarded.has(artifactId)) ||
625
+ [...affected].some(entityId => entities.get(entityId)?.artifactId &&
626
+ !guarded.has(entities.get(entityId).artifactId))) return result(false);
627
+ }
628
+ // Validate the complete bounded answer before treating any omission as a
629
+ // withdrawal. A malformed or stale answer cannot erase a newer boundary.
630
+ const incoming = new Map();
631
+ for (const value of values) {
632
+ if (!plain(value) || value.namespace !== undefined && value.namespace !== namespace) return result(false);
633
+ const record = prepareInterpretation({ ...value, namespace });
634
+ if (!record || record.validity !== 'current' || !matches(record) || incoming.has(record.id) ||
635
+ record.entityIds.some(entityId => entities.get(entityId).validity !== 'current') ||
636
+ record.sourceRefs.length && !currentInterpretationRefs(record.sourceRefs)) return result(false);
637
+ const old = interpretations.get(record.id);
638
+ if (old && !matches(old)) return result(false);
639
+ incoming.set(record.id, record);
640
+ }
641
+ const obsolete = new Set([...interpretations.values()].filter(record =>
642
+ record.namespace === namespace && matches(record) && !incoming.has(record.id)).map(record => record.id));
643
+ const updates = [...incoming.values()].map(record => ({
644
+ record, delta: byteSize(record) - (weights.get(interpretations.get(record.id)) ?? 0),
645
+ }));
646
+ let plannedCount = interpretations.size - obsolete.size +
647
+ updates.filter(({ record }) => !interpretations.has(record.id)).length;
648
+ let plannedBytes = bytes + historyBytes + updates.reduce((sum, update) => sum + update.delta, 0);
649
+ for (const recordId of obsolete) plannedBytes -= weights.get(interpretations.get(recordId)) ?? 0;
650
+ const victims = [];
651
+ const fits = () => plannedCount <= limits.interpretations && plannedBytes <= limits.bytes;
652
+ if (namespace === 'graphlin.architecture') {
653
+ for (const record of interpretations.values()) {
654
+ if (fits()) break;
655
+ if (record.namespace !== 'graphlin.legacy-role' || obsolete.has(record.id) || incoming.has(record.id)) continue;
656
+ victims.push(record.id);
657
+ plannedCount--;
658
+ plannedBytes -= weights.get(record) ?? 0;
659
+ }
660
+ }
661
+ // Admission is all-or-nothing, including prospective legacy evictions.
662
+ // Capacity defers a valid answer; it must not withdraw supported old roles.
663
+ if (!fits()) {
664
+ deferred.interpretations += incoming.size;
665
+ commit('interpretations.replaced', { sessionId: options.event?.sessionId });
666
+ return result(true, true);
667
+ }
668
+ const before = mutations, oldDeferred = deferred.interpretations;
669
+ for (const recordId of obsolete) remove(interpretations, recordId, 'interpretations');
670
+ for (const recordId of victims) {
671
+ remove(interpretations, recordId, 'interpretations');
672
+ deferred.interpretations++;
673
+ }
674
+ // Shrink existing records first so intermediate writes also fit the plan.
675
+ updates.sort((a, b) => Number(a.delta > 0) - Number(b.delta > 0) ||
676
+ (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0));
677
+ for (const { record } of updates) {
678
+ put(interpretations, record.id, record, limits.interpretations, 'interpretations');
679
+ }
680
+ const changed = mutations !== before || oldDeferred !== deferred.interpretations;
681
+ if (changed) commit('interpretations.replaced', { sessionId: options.event?.sessionId });
682
+ return result(true, changed, obsolete.size, incoming.size);
683
+ }
684
+
552
685
  function decisionFreshness(value, sourceRefs, fresh) {
553
686
  if (!fresh) return 'stale';
554
687
  if (value.validity === 'retracted') return 'retracted';
@@ -618,9 +751,7 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
618
751
  classification: entity.classification, version: '1', sessionId,
619
752
  support: validity !== 'current' ? 'unknown' : entity.classification === 'accepted' ? 'supported' : 'tentative',
620
753
  }, limits.refs);
621
- if (interpretation && !put(interpretations, interpretation.id, interpretation, limits.interpretations, 'interpretations')) {
622
- deferred.interpretations++;
623
- }
754
+ if (interpretation) admitInterpretation(interpretation);
624
755
  }
625
756
  }
626
757
  for (const edge of (Array.isArray(graph.edges) ? graph.edges : []).slice(0, limits.relations)) {
@@ -883,7 +1014,7 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
883
1014
  }
884
1015
  restore(restoredState);
885
1016
  return Object.freeze({
886
- observeInventory, observeStructure, invalidateArtifacts, observeLegacy, observeInterpretations,
1017
+ observeInventory, observeStructure, invalidateArtifacts, observeLegacy, observeInterpretations, replaceInterpretations,
887
1018
  recordActivity, setSessions, observeLineage, snapshot, checkpoint, changes, stats,
888
1019
  });
889
1020
  }
@@ -17,6 +17,8 @@ export const validity = value => ['current', 'stale', 'retracted'].includes(valu
17
17
  export const classification = value => ['accepted', 'tentative', 'unknown', 'stale', 'pending', 'abstained'].includes(value)
18
18
  ? value : 'unknown';
19
19
  export const basis = value => ['metadata', 'parsed', 'lexical', 'decision', 'legacy'].includes(value) ? value : 'legacy';
20
+ export const interpretationNamespace = value => typeof value === 'string' &&
21
+ /^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)+$/.test(value) && safeText(value, 80) ? value : null;
20
22
  export const byteSize = value => Buffer.byteLength(JSON.stringify(value));
21
23
  export const key = (prefix, ...parts) => opaque(prefix, ...parts);
22
24
  const version = value => typeof value === 'string' && value.length <= 256 &&
@@ -102,8 +104,7 @@ export function relationRecord(value, maxRefs) {
102
104
  }
103
105
 
104
106
  export function interpretationRecord(value, maxRefs) {
105
- if (!plain(value) || !id(value.id) || typeof value.namespace !== 'string' ||
106
- !/^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)+$/.test(value.namespace) || !safeText(value.namespace, 80)) return null;
107
+ if (!plain(value) || !id(value.id) || !interpretationNamespace(value.namespace)) return null;
107
108
  const sourceRefs = references(value.sourceRefs ?? [], maxRefs);
108
109
  if (!sourceRefs || !Array.isArray(value.entityIds) || value.entityIds.length > 256 ||
109
110
  value.entityIds.some(value => !id(value))) return null;