@unson/brainbase-mcp 0.41.8 → 0.41.10

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.
@@ -31,6 +31,8 @@ export interface CanonicalTaskRecord extends JsonRecord {
31
31
  assignee_display_name?: string | null;
32
32
  due_at?: string | null;
33
33
  waiting_on?: string | null;
34
+ /** The Graph person a waiting task waits on; null when it waits on a condition or nobody in particular. */
35
+ waiting_on_person_id?: string | null;
34
36
  review_at?: string | null;
35
37
  completed_at?: string | null;
36
38
  source_refs?: unknown[];
@@ -50,6 +52,7 @@ export interface CanonicalTaskCreateInput extends JsonRecord {
50
52
  assignee_person_id?: string | null;
51
53
  due_at?: string | null;
52
54
  waiting_on?: string | null;
55
+ waiting_on_person_id?: string | null;
53
56
  review_at?: string | null;
54
57
  source_refs?: unknown[];
55
58
  project_codes?: unknown;
@@ -63,6 +66,7 @@ export interface CanonicalTaskUpdateInput extends JsonRecord {
63
66
  assignee_person_id?: string | null;
64
67
  due_at?: string | null;
65
68
  waiting_on?: string | null;
69
+ waiting_on_person_id?: string | null;
66
70
  review_at?: string | null;
67
71
  source_refs?: unknown[];
68
72
  source_refs_append?: unknown[];
@@ -231,12 +235,14 @@ export declare class CanonicalTaskService {
231
235
  }, context: CanonicalTaskContext): Promise<CanonicalTaskRecord>;
232
236
  transitionTask(taskId: string, status: unknown, expectedVersion: number, context: CanonicalTaskContext, input?: {
233
237
  waitingOn?: unknown;
238
+ waitingOnPersonId?: unknown;
234
239
  reviewAt?: unknown;
235
240
  }): Promise<CanonicalTaskRecord>;
236
241
  transitionTask(taskId: string, input: {
237
242
  to_status?: unknown;
238
243
  expected_version?: number;
239
244
  waiting_on?: unknown;
245
+ waiting_on_person_id?: unknown;
240
246
  review_at?: unknown;
241
247
  }, context: CanonicalTaskContext): Promise<CanonicalTaskRecord>;
242
248
  deleteTask(taskId: string, expectedVersion: number, context: CanonicalTaskContext): Promise<{
@@ -34,6 +34,7 @@ const CREATE_FIELDS = new Set([
34
34
  'assignee_person_id',
35
35
  'due_at',
36
36
  'waiting_on',
37
+ 'waiting_on_person_id',
37
38
  'review_at',
38
39
  'source_refs',
39
40
  'project_codes',
@@ -48,6 +49,7 @@ const MUTABLE_FIELDS = new Set([
48
49
  'assignee_person_id',
49
50
  'due_at',
50
51
  'waiting_on',
52
+ 'waiting_on_person_id',
51
53
  'review_at',
52
54
  'source_refs',
53
55
  'source_refs_append',
@@ -78,6 +80,10 @@ function normalizeString(value, field, options = {}) {
78
80
  }
79
81
  return normalized;
80
82
  }
83
+ /** A Graph person ID, as for the assignee; empty or null means no person. */
84
+ function normalizeWaitingOnPersonId(value) {
85
+ return normalizeString(value, 'waiting_on_person_id', { max: 200 });
86
+ }
81
87
  /** A purpose label: a single line of at most 30 characters; empty or null clears it. */
82
88
  const MAX_PURPOSE_LABEL_LENGTH = 30;
83
89
  function normalizePurposeLabel(value) {
@@ -511,7 +517,7 @@ export class CanonicalTaskService {
511
517
  const expectedVersion = legacyInput ? legacyInput.expected_version : expectedVersionOrContext;
512
518
  const context = legacyInput ? expectedVersionOrContext : maybeContext;
513
519
  const input = legacyInput
514
- ? { waitingOn: legacyInput.waiting_on, reviewAt: legacyInput.review_at }
520
+ ? { waitingOn: legacyInput.waiting_on, waitingOnPersonId: legacyInput.waiting_on_person_id, reviewAt: legacyInput.review_at }
515
521
  : maybeInput;
516
522
  const normalizedContext = await this.normalizeContext(context);
517
523
  const expected = this.requireExpectedVersion(expectedVersion);
@@ -523,10 +529,13 @@ export class CanonicalTaskService {
523
529
  failValidation({ waiting_on: ['required_for_waiting'] });
524
530
  }
525
531
  patch.waiting_on = normalizeString(input.waitingOn, 'waiting_on', { required: true, max: 500 });
532
+ // The person is optional and always replaced, so waiting on a condition clears an earlier person.
533
+ patch.waiting_on_person_id = normalizeWaitingOnPersonId(input.waitingOnPersonId);
526
534
  patch.review_at = normalizeIsoDate(input.reviewAt, 'review_at');
527
535
  }
528
536
  else {
529
537
  patch.waiting_on = input.waitingOn === undefined ? null : normalizeString(input.waitingOn, 'waiting_on', { max: 500 });
538
+ patch.waiting_on_person_id = input.waitingOnPersonId === undefined ? null : normalizeWaitingOnPersonId(input.waitingOnPersonId);
530
539
  patch.review_at = input.reviewAt === undefined ? null : normalizeIsoDate(input.reviewAt, 'review_at');
531
540
  }
532
541
  return this.versionedMutation({
@@ -801,6 +810,7 @@ export class CanonicalTaskService {
801
810
  transition: {
802
811
  to_status: input.transitionStatus,
803
812
  ...(input.patch.waiting_on !== undefined ? { waiting_on: input.patch.waiting_on } : {}),
813
+ ...(input.patch.waiting_on_person_id !== undefined ? { waiting_on_person_id: input.patch.waiting_on_person_id } : {}),
804
814
  ...(input.patch.review_at !== undefined ? { review_at: input.patch.review_at } : {}),
805
815
  },
806
816
  };
@@ -818,6 +828,7 @@ export class CanonicalTaskService {
818
828
  if (!isCanonicalTaskPriority(priority))
819
829
  fail('validation_error', 'priority is invalid', 400, { field: 'priority' });
820
830
  const purposeLabel = normalizePurposeLabel(input.purpose_label);
831
+ const waitingOnPersonId = normalizeWaitingOnPersonId(input.waiting_on_person_id);
821
832
  const completionContract = normalizeCompletionContract(input.completion_contract);
822
833
  return {
823
834
  ...(completionContract ? { completion_contract: completionContract } : {}),
@@ -832,6 +843,8 @@ export class CanonicalTaskService {
832
843
  project_codes: normalizeProjectCodes(input.project_codes) ?? [],
833
844
  // Only a label that is there is written, so a store without the column still accepts unlabelled work.
834
845
  ...(purposeLabel ? { purpose_label: purposeLabel } : {}),
846
+ // Likewise only a person that is there is written.
847
+ ...(waitingOnPersonId ? { waiting_on_person_id: waitingOnPersonId } : {}),
835
848
  };
836
849
  }
837
850
  normalizeUpdateInput(input) {
@@ -866,6 +879,8 @@ export class CanonicalTaskService {
866
879
  patch.due_at = normalizeIsoDate(input.due_at, 'due_at');
867
880
  if ('waiting_on' in input)
868
881
  patch.waiting_on = normalizeString(input.waiting_on, 'waiting_on', { max: 500 });
882
+ if ('waiting_on_person_id' in input)
883
+ patch.waiting_on_person_id = normalizeWaitingOnPersonId(input.waiting_on_person_id);
869
884
  if ('review_at' in input)
870
885
  patch.review_at = normalizeIsoDate(input.review_at, 'review_at');
871
886
  if ('source_refs' in input && hasSourceRefsAppend) {
@@ -1052,6 +1067,8 @@ export class CanonicalTaskService {
1052
1067
  response.due_at = null;
1053
1068
  if (response.waiting_on === undefined)
1054
1069
  response.waiting_on = null;
1070
+ if (response.waiting_on_person_id === undefined)
1071
+ response.waiting_on_person_id = null;
1055
1072
  if (response.review_at === undefined)
1056
1073
  response.review_at = null;
1057
1074
  if (response.completed_at === undefined)
@@ -253,13 +253,17 @@ function createFoundationCatalogRoute(readers, scopeId, context) {
253
253
  const variables = await readers.store.list('variable', context);
254
254
  const models = await readers.store.list('model', context);
255
255
  const philosophies = await readers.listPhilosophies(context);
256
+ // Philosophies without recorded applicability are still read by the
257
+ // judgment frame catalog; report them separately rather than as absent.
258
+ const philosophiesWithoutApplicability = await readers.listUnscopedPhilosophies(context);
256
259
  return jsonResponse(200, {
257
260
  contractVersion: FOUNDATION_OVERVIEW_CONTRACT_VERSION,
258
261
  scopeId,
259
262
  objectives,
260
263
  variables,
261
264
  models,
262
- philosophies
265
+ philosophies,
266
+ philosophies_without_applicability: philosophiesWithoutApplicability
263
267
  });
264
268
  }
265
269
  };
@@ -25,7 +25,7 @@ export declare const GRAPH_FOUNDATION_LATEST_SQL = "\nSELECT\n history.entity_i
25
25
  * project. The project filter is by canonical project code, which is the
26
26
  * scope identifier selected by the organization BFF.
27
27
  */
28
- export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nLEFT JOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE ($1::text IS NULL OR current_entity.entity_type = $1)\n AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)\n AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')\nORDER BY current_entity.id, current_entity.entity_type\n";
28
+ export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nLEFT JOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE ($1::text IS NULL OR current_entity.entity_type = $1)\n AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)\n AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')\n -- A list projects current records only. A retired row is not current, and\n -- reading it would make one replaced record fail the whole list.\n AND current_entity.lifecycle_status = 'active'\nORDER BY current_entity.id, current_entity.entity_type\n";
29
29
  /**
30
30
  * List the newest immutable revision for each philosophy attached to the
31
31
  * selected project. The current Graph row is still the authorization
@@ -33,7 +33,29 @@ export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,
33
33
  * Unlike the Foundation list, this query requires a canonical project join so
34
34
  * a missing project membership cannot be interpreted as a selected-scope hit.
35
35
  */
36
- export declare const GRAPH_PHILOSOPHY_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE current_entity.entity_type = 'philosophy'\n AND projects.code = $1\n AND current_entity.payload ? 'judgmentApplicability'\nORDER BY current_entity.id\n";
36
+ export declare const GRAPH_PHILOSOPHY_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE current_entity.entity_type = 'philosophy'\n AND projects.code = $1\n AND current_entity.lifecycle_status = 'active'\n AND current_entity.payload ? 'judgmentApplicability'\nORDER BY current_entity.id\n";
37
+ /**
38
+ * Current philosophies of the selected project that predate recorded
39
+ * applicability. They are excluded from the verified list above, but the
40
+ * judgment frame catalog still reads them, so the overview must show them as
41
+ * "applicability unrecorded" instead of reporting that none are registered.
42
+ */
43
+ export declare const GRAPH_UNSCOPED_PHILOSOPHY_LIST_SQL = "\nSELECT\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.lifecycle_status AS current_lifecycle_status,\n current_entity.version AS current_version,\n projects.code AS current_project_code\nFROM public.graph_entities AS current_entity\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nWHERE current_entity.entity_type = 'philosophy'\n AND projects.code = $1\n AND current_entity.lifecycle_status = 'active'\n AND NOT (current_entity.payload ? 'judgmentApplicability')\nORDER BY current_entity.id\n";
44
+ /** A current philosophy whose judgment applicability has never been recorded. */
45
+ export interface UnscopedPhilosophyRecord {
46
+ readonly kind: 'philosophy';
47
+ readonly id: string;
48
+ /** Current Graph version; there is no verified foundation revision. */
49
+ readonly version: number;
50
+ readonly applicability: null;
51
+ readonly applicability_state: 'unrecorded';
52
+ readonly payload: {
53
+ readonly title?: string;
54
+ readonly statement: string;
55
+ readonly summary?: string;
56
+ readonly status?: string;
57
+ };
58
+ }
37
59
  /** Flat row shape returned by GRAPH_FOUNDATION_READ_SQL. */
38
60
  export interface GraphFoundationHistoryRow {
39
61
  readonly entity_id?: unknown;
@@ -55,6 +77,7 @@ export interface GraphFoundationHistoryRow {
55
77
  readonly current_role_min?: unknown;
56
78
  readonly current_sensitivity?: unknown;
57
79
  readonly current_lifecycle_status?: unknown;
80
+ readonly current_version?: unknown;
58
81
  readonly current_visible?: unknown;
59
82
  }
60
83
  export interface GraphFoundationQueryResult {
@@ -91,6 +114,8 @@ export interface GraphFoundationReaders {
91
114
  readonly philosophyReader: PhilosophyRevisionReader;
92
115
  /** List latest, current-visible philosophy revisions for the selected project. */
93
116
  readonly listPhilosophies: (context: FoundationStoreContext) => Promise<PhilosophyRevisionRecord[]>;
117
+ /** List current philosophies of the selected project without recorded applicability. */
118
+ readonly listUnscopedPhilosophies: (context: FoundationStoreContext) => Promise<UnscopedPhilosophyRecord[]>;
94
119
  }
95
120
  /**
96
121
  * Create both public read ports over one trusted, transaction-bound context.
@@ -141,6 +141,9 @@ LEFT JOIN LATERAL (
141
141
  WHERE ($1::text IS NULL OR current_entity.entity_type = $1)
142
142
  AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)
143
143
  AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')
144
+ -- A list projects current records only. A retired row is not current, and
145
+ -- reading it would make one replaced record fail the whole list.
146
+ AND current_entity.lifecycle_status = 'active'
144
147
  ORDER BY current_entity.id, current_entity.entity_type
145
148
  `;
146
149
  /**
@@ -188,9 +191,34 @@ LEFT JOIN LATERAL (
188
191
  ) AS history ON true
189
192
  WHERE current_entity.entity_type = 'philosophy'
190
193
  AND projects.code = $1
194
+ AND current_entity.lifecycle_status = 'active'
191
195
  AND current_entity.payload ? 'judgmentApplicability'
192
196
  ORDER BY current_entity.id
193
197
  `;
198
+ /**
199
+ * Current philosophies of the selected project that predate recorded
200
+ * applicability. They are excluded from the verified list above, but the
201
+ * judgment frame catalog still reads them, so the overview must show them as
202
+ * "applicability unrecorded" instead of reporting that none are registered.
203
+ */
204
+ export const GRAPH_UNSCOPED_PHILOSOPHY_LIST_SQL = `
205
+ SELECT
206
+ current_entity.id AS current_entity_id,
207
+ current_entity.entity_type AS current_entity_type,
208
+ current_entity.payload AS current_payload,
209
+ current_entity.project_id AS current_project_id,
210
+ current_entity.lifecycle_status AS current_lifecycle_status,
211
+ current_entity.version AS current_version,
212
+ projects.code AS current_project_code
213
+ FROM public.graph_entities AS current_entity
214
+ JOIN public.projects AS projects
215
+ ON projects.id = current_entity.project_id
216
+ WHERE current_entity.entity_type = 'philosophy'
217
+ AND projects.code = $1
218
+ AND current_entity.lifecycle_status = 'active'
219
+ AND NOT (current_entity.payload ? 'judgmentApplicability')
220
+ ORDER BY current_entity.id
221
+ `;
194
222
  const FOUNDATION_TYPES = ['objective', 'variable', 'model', 'constraint'];
195
223
  const REVISION_PATTERN = /^[1-9]\d*$/u;
196
224
  const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
@@ -284,7 +312,75 @@ export function createGraphFoundationReaders(options) {
284
312
  return createPhilosophyRevisionRecord(validated, id, revision, context.principal);
285
313
  });
286
314
  }
287
- return { store, philosophyReader, listPhilosophies };
315
+ async function listUnscopedPhilosophies(suppliedContext) {
316
+ assertTrustedPrincipal(context, suppliedContext);
317
+ if (selectedProjectCode === undefined) {
318
+ throw scopeViolation('A selected project is required to list philosophies');
319
+ }
320
+ let result;
321
+ try {
322
+ result = await query(GRAPH_UNSCOPED_PHILOSOPHY_LIST_SQL, [selectedProjectCode]);
323
+ }
324
+ catch {
325
+ throw corrupt('Graph unscoped philosophy list query failed');
326
+ }
327
+ const rows = Array.isArray(result) ? result
328
+ : 'rows' in result && Array.isArray(result.rows) ? result.rows
329
+ : null;
330
+ if (!rows)
331
+ throw corrupt('Graph unscoped philosophy list query returned an invalid result');
332
+ const seen = new Set();
333
+ const records = [];
334
+ for (const row of rows) {
335
+ const id = requireString(row.current_entity_id, 'Current philosophy id');
336
+ if (row.current_entity_type !== 'philosophy')
337
+ throw corrupt('Graph philosophy list returned another entity type');
338
+ if (row.current_project_code !== selectedProjectCode) {
339
+ throw scopeViolation('The current philosophy project is outside the selected project scope');
340
+ }
341
+ if (seen.has(id))
342
+ throw corrupt('Philosophy list returned duplicate entities');
343
+ seen.add(id);
344
+ const version = Number(row.current_version);
345
+ if (!Number.isSafeInteger(version) || version < 1)
346
+ throw corrupt('Current philosophy version is invalid');
347
+ const payload = requireObject(row.current_payload, 'Current philosophy payload');
348
+ if (Object.hasOwn(payload, 'judgmentApplicability')) {
349
+ throw corrupt('Unscoped philosophy list returned a philosophy with applicability');
350
+ }
351
+ // Private philosophies always carry applicability; an owner-only ACL here
352
+ // is still enforced rather than silently shown.
353
+ assertPrivatePhilosophyRead(payload, context.principal);
354
+ // Mirror the judgment frame catalog: superseded or text-less records are
355
+ // not used for judgment, so they are not shown as usable either.
356
+ const status = optionalText(payload.status);
357
+ if (status && /^(superseded|retired|rejected)/u.test(status))
358
+ continue;
359
+ const statement = optionalText(payload.statement);
360
+ if (!statement)
361
+ continue;
362
+ const title = optionalText(payload.display_name) ?? optionalText(payload.title);
363
+ const summary = optionalText(payload.summary);
364
+ records.push({
365
+ kind: 'philosophy',
366
+ id,
367
+ version,
368
+ applicability: null,
369
+ applicability_state: 'unrecorded',
370
+ payload: {
371
+ ...(title ? { title } : {}),
372
+ statement,
373
+ ...(summary ? { summary } : {}),
374
+ ...(status ? { status } : {})
375
+ }
376
+ });
377
+ }
378
+ return records;
379
+ }
380
+ return { store, philosophyReader, listPhilosophies, listUnscopedPhilosophies };
381
+ }
382
+ function optionalText(value) {
383
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
288
384
  }
289
385
  function createPhilosophyRevisionRecord(validated, id, revision, principal) {
290
386
  return {
@@ -44,6 +44,9 @@ function routeFor(pathname, basePath) {
44
44
  if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'confirm') {
45
45
  return { kind: 'confirm', meetingId, minutesId };
46
46
  }
47
+ if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'withdraw') {
48
+ return { kind: 'withdraw', meetingId, minutesId };
49
+ }
47
50
  if (segments.length === 5 && noun === 'minutes' && minutesId && action === 'versions' && versionId) {
48
51
  return { kind: 'version', meetingId, minutesId, versionId };
49
52
  }
@@ -157,6 +160,13 @@ function confirmInput(body, minutesId, key, fingerprint) {
157
160
  ...idempotencyFields(key, fingerprint)
158
161
  };
159
162
  }
163
+ function withdrawInput(body, minutesId, key, fingerprint) {
164
+ return {
165
+ minutes_id: minutesId,
166
+ expected_revision: body.expected_revision,
167
+ ...idempotencyFields(key, fingerprint)
168
+ };
169
+ }
160
170
  export function createMeetingMinutesHttpHandler(options) {
161
171
  if (!options || !options.store || typeof options.resolveContext !== 'function') {
162
172
  throw new TypeError('store and resolveContext are required');
@@ -202,6 +212,15 @@ export function createMeetingMinutesHttpHandler(options) {
202
212
  }
203
213
  return { statusCode: 200, body: await options.store.confirm_version(confirmInput(body, document.minutes_id, key, fingerprint), context) };
204
214
  }
215
+ case 'withdraw': {
216
+ if (body === undefined)
217
+ throw new MeetingMinutesError('invalid_input', 'Request body is required');
218
+ const detail = await options.store.get_meeting(route.meetingId ?? '', context);
219
+ const document = detail.minutes.find((candidate) => candidate.minutes_id === route.minutesId);
220
+ if (!document)
221
+ throw new MeetingMinutesError('not_found', `Minutes document ${route.minutesId} was not found`);
222
+ return { statusCode: 200, body: await options.store.withdraw_minutes(withdrawInput(body, document.minutes_id, key, fingerprint), context) };
223
+ }
205
224
  case 'version':
206
225
  return {
207
226
  statusCode: 200,
@@ -1132,6 +1132,10 @@ export function createMeetingMinutesStore(options) {
1132
1132
  const meeting = findMeeting(snapshot, document.meeting_id);
1133
1133
  await authorized(authorizer, 'write', meeting, context);
1134
1134
  await authorized(authorizer, 'write', document, context);
1135
+ // Only the person who made the document takes it back (story-meeting-minutes-edit-and-withdraw-ui-v1).
1136
+ if (document.created_actor_id !== principal) {
1137
+ throw new MeetingMinutesError('authorization_denied', 'Only the person who made this minutes document can withdraw it');
1138
+ }
1135
1139
  // Withdrawing twice keeps the first record; a retry is not a second withdrawal.
1136
1140
  if (document.withdrawal && !idempotency.token)
1137
1141
  return detailForCaller(snapshot, meeting, context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.41.8",
3
+ "version": "0.41.10",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -483,5 +483,5 @@
483
483
  "engines": {
484
484
  "node": ">=20"
485
485
  },
486
- "gitHead": "b061e1b0a7ccb22155c6e22459ad735dc5e7adb1"
486
+ "gitHead": "9ea5345d8da4b0cf825daf73610474dd8903a8f5"
487
487
  }
@@ -148,6 +148,22 @@ function validatePhilosophyRecords(value) {
148
148
  return value;
149
149
  }
150
150
 
151
+ function validateUnscopedPhilosophyRecords(value) {
152
+ if (!Array.isArray(value)) throw error('invalid_schema', '適用範囲が未記録の哲学の形式を確認できません。');
153
+ for (const [index, item] of value.entries()) {
154
+ if (!isObject(item)
155
+ || item.kind !== 'philosophy'
156
+ || !nonEmpty(item.id)
157
+ || !Number.isSafeInteger(item.version) || item.version < 1
158
+ || item.applicability_state !== 'unrecorded'
159
+ || !isObject(item.payload)
160
+ || !nonEmpty(item.payload.statement)) {
161
+ throw error('invalid_schema', `適用範囲が未記録の哲学の${index + 1}件目の形式が不正です。`);
162
+ }
163
+ }
164
+ return value;
165
+ }
166
+
151
167
  /** Validate the host contract without converting an unreadable response into an empty catalog. */
152
168
  export function normalizeFoundationCatalog(value, expectedScopeId) {
153
169
  if (!isObject(value) || value.contractVersion !== FOUNDATION_OVERVIEW_CONTRACT_VERSION) {
@@ -163,7 +179,10 @@ export function normalizeFoundationCatalog(value, expectedScopeId) {
163
179
  const knownReadableStatuses = new Set(['complete', 'ready', 'resolved', 'empty']);
164
180
  const unreadableStatus = (readStatus && !knownReadableStatuses.has(readStatus))
165
181
  || (catalogStatus && !knownReadableStatuses.has(catalogStatus));
166
- const collections = [value.objectives, value.variables, value.models, value.philosophies];
182
+ // Older hosts omit this field; then it stays unknown instead of becoming [].
183
+ const unscopedPhilosophies = value.philosophies_without_applicability === undefined
184
+ ? undefined : validateUnscopedPhilosophyRecords(value.philosophies_without_applicability);
185
+ const collections = [value.objectives, value.variables, value.models, value.philosophies, unscopedPhilosophies];
167
186
  const hasRecords = collections.some((records) => Array.isArray(records) && records.length > 0);
168
187
  const absenceConfirmed = value.absence_confirmed === true;
169
188
  if (unreadableStatus || (!hasRecords && ('absence_confirmed' in value || readStatus || catalogStatus) && !absenceConfirmed)) {
@@ -180,6 +199,7 @@ export function normalizeFoundationCatalog(value, expectedScopeId) {
180
199
  variables: value.variables,
181
200
  models: value.models,
182
201
  philosophies: value.philosophies,
202
+ ...(unscopedPhilosophies ? { philosophies_without_applicability: unscopedPhilosophies } : {}),
183
203
  ...(readStatus ? { read_status: readStatus } : {}),
184
204
  ...(catalogStatus ? { status: catalogStatus } : {}),
185
205
  ...(typeof value.absence_confirmed === 'boolean' ? { absence_confirmed: value.absence_confirmed } : {}),
@@ -589,21 +609,53 @@ function philosophyCard(doc, record) {
589
609
  return card;
590
610
  }
591
611
 
612
+ function unscopedPhilosophyCard(doc, record, recordContexts) {
613
+ const payload = record.payload;
614
+ const card = makeElement(doc, 'article', { className: 'bb-fov-philosophy-card is-unscoped' });
615
+ card.append(makeElement(doc, 'h3', { text: formatAny(payload.title) ?? UNNAMED_RECORD }));
616
+ card.append(makeElement(doc, 'p', { className: 'bb-fov-philosophy-statement', text: payload.statement }));
617
+ if (formatAny(payload.summary) && payload.summary !== payload.statement) card.append(makeElement(doc, 'p', { text: payload.summary }));
618
+ // The section notice explains what an unrecorded applicability means.
619
+ card.append(workspaceDefinition(doc, [['適用範囲', '未記録']]));
620
+ const refs = makeElement(doc, 'details', { className: 'bb-fov-original' });
621
+ refs.append(makeElement(doc, 'summary', { text: '記録の詳細' }), workspaceDefinition(doc, [['出典', recordContexts?.get(record)?.label ?? '未記録'], ['状態', payload.status], ['Graphの版', record.version], ['記録ID', record.id]]));
622
+ card.append(refs);
623
+ return card;
624
+ }
625
+
592
626
  function renderPhilosophies(doc, catalog, recordContexts) {
593
627
  const section = makeElement(doc, 'section', { className: 'bb-fov-section', attrs: { 'aria-label': '哲学' } });
594
-
595
- if (catalog.philosophies.length === 0) {
596
- section.append(workspaceNotice(doc, { label: '哲学', text: 'この対象範囲には登録された哲学がありません。' }));
628
+ const unscoped = catalog.philosophies_without_applicability;
629
+
630
+ if (catalog.philosophies.length === 0 && !unscoped?.length) {
631
+ section.append(workspaceNotice(doc, {
632
+ label: '哲学',
633
+ text: unscoped
634
+ ? 'この対象範囲には登録された哲学がありません。'
635
+ // Without the unscoped list, only the verified list is known to be empty.
636
+ : '適用範囲が記録された哲学はありません。適用範囲が未記録の哲学があるかは確認できていません。',
637
+ }));
597
638
  return section;
598
639
  }
599
- const grid = makeElement(doc, 'div', { className: 'bb-fov-philosophy-grid' });
600
- for (const record of catalog.philosophies) {
601
- const card = philosophyCard(doc, record);
602
- const refs = makeElement(doc, 'details', { className: 'bb-fov-original' });
603
- refs.append(makeElement(doc, 'summary', { text: '記録の詳細' }), workspaceDefinition(doc, [['出典', recordContexts?.get(record)?.label ?? '未記録'], ['状態', record.payload.status], ['版', record.revision], ['記録ID', record.id], ['digest', record.digest]]));
604
- card.append(refs); grid.append(card);
640
+ if (catalog.philosophies.length) {
641
+ const grid = makeElement(doc, 'div', { className: 'bb-fov-philosophy-grid' });
642
+ for (const record of catalog.philosophies) {
643
+ const card = philosophyCard(doc, record);
644
+ const refs = makeElement(doc, 'details', { className: 'bb-fov-original' });
645
+ refs.append(makeElement(doc, 'summary', { text: '記録の詳細' }), workspaceDefinition(doc, [['出典', recordContexts?.get(record)?.label ?? '未記録'], ['状態', record.payload.status], ['版', record.revision], ['記録ID', record.id], ['digest', record.digest]]));
646
+ card.append(refs); grid.append(card);
647
+ }
648
+ section.append(grid);
649
+ }
650
+ if (unscoped?.length) {
651
+ section.append(workspaceNotice(doc, {
652
+ label: '適用範囲が未記録',
653
+ text: `次の${unscoped.length}件は、判断への適用範囲が記録されていない哲学です。判断では参照されますが、どの範囲に当てはまるかは確かめられていません。`,
654
+ }));
655
+ const grid = makeElement(doc, 'div', { className: 'bb-fov-philosophy-grid' });
656
+ for (const record of unscoped) grid.append(unscopedPhilosophyCard(doc, record, recordContexts));
657
+ section.append(grid);
605
658
  }
606
- section.append(grid);
607
659
  return section;
608
660
  }
609
661
 
@@ -829,10 +881,11 @@ export function renderFoundationCollectionOverview(root, sources, {
829
881
  } = {}) {
830
882
  const doc = getDocument(explicitDocument);
831
883
  root.replaceChildren();
832
- const catalog = { objectives: [], variables: [], models: [], philosophies: [] };
884
+ const catalog = { objectives: [], variables: [], models: [], philosophies: [], philosophies_without_applicability: [] };
833
885
  const contexts = new WeakMap();
834
886
  const seen = new Map();
835
887
  let ready = 0;
888
+ let unscopedUnknown = false;
836
889
  for (const source of sources) {
837
890
  if (source.state.status !== 'ready') {
838
891
  if (source.state.status === 'loading') {
@@ -846,8 +899,9 @@ export function renderFoundationCollectionOverview(root, sources, {
846
899
  const value = normalizeFoundationCatalog(source.state.catalog, source.scopeId);
847
900
  ready++;
848
901
  const variableMap = new Map(value.variables.map((record) => [foundationKey(recordDefinition(record)), record]));
849
- for (const kind of ['objectives', 'models', 'philosophies']) {
850
- for (const original of value[kind]) {
902
+ if (!value.philosophies_without_applicability) unscopedUnknown = true;
903
+ for (const kind of ['objectives', 'models', 'philosophies', 'philosophies_without_applicability']) {
904
+ for (const original of value[kind] ?? []) {
851
905
  const definition = original.definition ?? original;
852
906
  const scope = original.scope ?? original.currentScope ?? definition.scope;
853
907
  // Scope-less/project records remain tied to their authenticated catalog.
@@ -868,6 +922,8 @@ export function renderFoundationCollectionOverview(root, sources, {
868
922
  }
869
923
  }
870
924
  }
925
+ // A host that did not report unscoped philosophies leaves the absence unknown.
926
+ if (unscopedUnknown && catalog.philosophies_without_applicability.length === 0) delete catalog.philosophies_without_applicability;
871
927
  if (ready) {
872
928
  const mount = makeElement(doc, 'div');
873
929
  renderFoundationOverview(mount, { status: 'ready', catalog }, {