@unson/brainbase-mcp 0.11.0 → 0.13.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.
- package/contracts/judgment-value-proof/schema.json +19 -1
- package/dist/judgment-problem-snapshot-http.d.ts +40 -0
- package/dist/judgment-problem-snapshot-http.js +255 -0
- package/dist/judgment-value-proof.d.ts +15 -0
- package/dist/judgment-value-proof.js +17 -0
- package/dist/knowledge-promotion.d.ts +61 -3
- package/dist/knowledge-promotion.js +112 -1
- package/dist/local-web-host.d.ts +4 -4
- package/dist/local-web-host.js +137 -12
- package/package.json +6 -2
- package/ui/graph-registry-view.js +27 -1
- package/ui/local-web-shell.js +80 -8
- package/ui/project-workspace.js +1 -1
- package/ui/value-proof-review.css +54 -0
- package/ui/value-proof-review.js +127 -5
- package/ui/world-model-view.css +61 -0
- package/ui/world-model-view.js +215 -10
|
@@ -154,7 +154,25 @@
|
|
|
154
154
|
"entity_id": { "type": "string", "minLength": 1 },
|
|
155
155
|
"application": { "type": "string", "minLength": 1 },
|
|
156
156
|
"layer": { "enum": ["philosophy", "objective", "world_model", "method", "constraint", "other"] },
|
|
157
|
-
"version": { "type": ["string", "null"] }
|
|
157
|
+
"version": { "type": ["string", "null"] },
|
|
158
|
+
"source": {
|
|
159
|
+
"oneOf": [
|
|
160
|
+
{ "type": "null" },
|
|
161
|
+
{ "$ref": "#/$defs/basis_source" }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"basis_source": {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"additionalProperties": false,
|
|
169
|
+
"required": ["kind", "entity_id", "entity_type"],
|
|
170
|
+
"properties": {
|
|
171
|
+
"kind": { "const": "local_graph" },
|
|
172
|
+
"entity_id": { "type": "string", "minLength": 1 },
|
|
173
|
+
"entity_type": { "enum": ["person", "org", "project", "decision"] },
|
|
174
|
+
"version": { "type": ["string", "null"] },
|
|
175
|
+
"digest": { "type": ["string", "null"] }
|
|
158
176
|
}
|
|
159
177
|
},
|
|
160
178
|
"inheritance_source": {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type JudgmentProblemReferenceProvider, type JudgmentProblemSnapshotAccessProvider } from './judgment-problem-snapshot.js';
|
|
3
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
4
|
+
/** Identity resolved by the host after authentication. Request bodies never supply it. */
|
|
5
|
+
export interface TrustedJudgmentProblemSnapshotRequestContext {
|
|
6
|
+
principal: string;
|
|
7
|
+
/** A non-empty value means that the host already verified the mutation origin. */
|
|
8
|
+
verifiedMutationOrigin?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The host binds the reference resolution (for example the Foundation store
|
|
12
|
+
* and its own resolvers for authority, resource and deadline) to the trusted
|
|
13
|
+
* request context. The access provider defaults to the snapshot read policy.
|
|
14
|
+
*/
|
|
15
|
+
export type JudgmentProblemSnapshotProviderFactory = (context: TrustedJudgmentProblemSnapshotRequestContext) => MaybePromise<{
|
|
16
|
+
referenceProvider: JudgmentProblemReferenceProvider;
|
|
17
|
+
accessProvider?: JudgmentProblemSnapshotAccessProvider;
|
|
18
|
+
}>;
|
|
19
|
+
export interface JudgmentProblemSnapshotHttpOptions {
|
|
20
|
+
/** Artifact root of the immutable snapshot store. */
|
|
21
|
+
root: string;
|
|
22
|
+
providerFactory: JudgmentProblemSnapshotProviderFactory;
|
|
23
|
+
basePath?: string;
|
|
24
|
+
bodyLimitBytes?: number;
|
|
25
|
+
/** Required for hosts that do not attach verifiedMutationOrigin to context. */
|
|
26
|
+
verifyMutationRequest?: (input: {
|
|
27
|
+
request: IncomingMessage;
|
|
28
|
+
context: TrustedJudgmentProblemSnapshotRequestContext;
|
|
29
|
+
}) => MaybePromise<boolean>;
|
|
30
|
+
}
|
|
31
|
+
/** A composable route handler. `false` means that the request is outside this route. */
|
|
32
|
+
export type JudgmentProblemSnapshotHttpHandler = (request: IncomingMessage, response: ServerResponse, context: TrustedJudgmentProblemSnapshotRequestContext | null) => Promise<boolean>;
|
|
33
|
+
/**
|
|
34
|
+
* Saves and reads immutable judgment problem snapshots over HTTP. The host
|
|
35
|
+
* authenticates, binds the principal, and supplies the reference resolution;
|
|
36
|
+
* this handler never takes identity from the request body.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createJudgmentProblemSnapshotHttpHandler(options: JudgmentProblemSnapshotHttpOptions): JudgmentProblemSnapshotHttpHandler;
|
|
39
|
+
export declare const createJudgmentProblemSnapshotRoute: typeof createJudgmentProblemSnapshotHttpHandler;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { JudgmentProblemSnapshotError, loadJudgmentProblemSnapshot, saveJudgmentProblemSnapshot, } from './judgment-problem-snapshot.js';
|
|
2
|
+
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
3
|
+
const DEFAULT_BASE_PATH = '/judgment-problem-snapshots';
|
|
4
|
+
const snapshotIdPattern = /^sha256:[0-9a-f]{64}$/u;
|
|
5
|
+
class HttpInputError extends Error {
|
|
6
|
+
}
|
|
7
|
+
class BodyLimitError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('Request body exceeds the configured limit');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function requestPath(request) {
|
|
13
|
+
try {
|
|
14
|
+
return new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return '/';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function headerValue(request, name) {
|
|
21
|
+
const value = request.headers[name.toLowerCase()];
|
|
22
|
+
return Array.isArray(value) ? value[0] : value;
|
|
23
|
+
}
|
|
24
|
+
function writeJson(response, statusCode, body, headers = {}) {
|
|
25
|
+
if (response.headersSent)
|
|
26
|
+
return;
|
|
27
|
+
response.statusCode = statusCode;
|
|
28
|
+
response.setHeader('Content-Type', 'application/json');
|
|
29
|
+
for (const [name, value] of Object.entries(headers))
|
|
30
|
+
response.setHeader(name, value);
|
|
31
|
+
response.end(JSON.stringify(body));
|
|
32
|
+
}
|
|
33
|
+
function writeError(response, statusCode, code, message, headers = {}) {
|
|
34
|
+
writeJson(response, statusCode, { error: { code, message } }, headers);
|
|
35
|
+
}
|
|
36
|
+
async function readJsonBody(request, limitBytes) {
|
|
37
|
+
const contentLength = Number(headerValue(request, 'content-length'));
|
|
38
|
+
if (Number.isFinite(contentLength) && contentLength > limitBytes) {
|
|
39
|
+
request.resume();
|
|
40
|
+
throw new BodyLimitError();
|
|
41
|
+
}
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let totalBytes = 0;
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
let settled = false;
|
|
46
|
+
const finish = (error) => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
request.removeListener('data', onData);
|
|
51
|
+
request.removeListener('end', onEnd);
|
|
52
|
+
request.removeListener('error', onError);
|
|
53
|
+
request.removeListener('aborted', onError);
|
|
54
|
+
if (error)
|
|
55
|
+
reject(error);
|
|
56
|
+
else
|
|
57
|
+
resolve();
|
|
58
|
+
};
|
|
59
|
+
const onData = (chunk) => {
|
|
60
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
61
|
+
totalBytes += buffer.byteLength;
|
|
62
|
+
if (totalBytes > limitBytes) {
|
|
63
|
+
request.resume();
|
|
64
|
+
finish(new BodyLimitError());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(buffer);
|
|
68
|
+
};
|
|
69
|
+
const onEnd = () => finish();
|
|
70
|
+
const onError = () => finish(new HttpInputError('Request body could not be read'));
|
|
71
|
+
request.on('data', onData);
|
|
72
|
+
request.once('end', onEnd);
|
|
73
|
+
request.once('error', onError);
|
|
74
|
+
request.once('aborted', onError);
|
|
75
|
+
});
|
|
76
|
+
if (chunks.length === 0)
|
|
77
|
+
throw new HttpInputError('Request body must be a JSON object');
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new HttpInputError('Request body must contain valid JSON');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function normalizeBasePath(basePath) {
|
|
86
|
+
const value = basePath ?? DEFAULT_BASE_PATH;
|
|
87
|
+
if (!value.startsWith('/'))
|
|
88
|
+
throw new TypeError('basePath must be an absolute URL path');
|
|
89
|
+
const normalized = value.replace(/\/+$/u, '');
|
|
90
|
+
if (normalized.length === 0)
|
|
91
|
+
throw new TypeError('basePath must not be root');
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
function matchRoute(pathname, basePath) {
|
|
95
|
+
const normalized = pathname.replace(/\/+$/u, '') || '/';
|
|
96
|
+
if (normalized === basePath)
|
|
97
|
+
return { operation: 'save' };
|
|
98
|
+
if (!normalized.startsWith(`${basePath}/`))
|
|
99
|
+
return null;
|
|
100
|
+
const rest = normalized.slice(basePath.length + 1);
|
|
101
|
+
if (rest.length === 0 || rest.includes('/'))
|
|
102
|
+
return { operation: 'unknown' };
|
|
103
|
+
try {
|
|
104
|
+
return { operation: 'read', snapshotId: decodeURIComponent(rest) };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { operation: 'read', snapshotId: null };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function isPlainRecord(value) {
|
|
111
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
112
|
+
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
113
|
+
}
|
|
114
|
+
function parseSaveBody(body) {
|
|
115
|
+
if (!isPlainRecord(body))
|
|
116
|
+
throw new HttpInputError('Request body must be a JSON object');
|
|
117
|
+
const keys = Object.keys(body);
|
|
118
|
+
if (keys.length !== 1 || keys[0] !== 'snapshot')
|
|
119
|
+
throw new HttpInputError('Request body must contain only snapshot');
|
|
120
|
+
if (!isPlainRecord(body.snapshot))
|
|
121
|
+
throw new HttpInputError('snapshot must be a JSON object');
|
|
122
|
+
return body.snapshot;
|
|
123
|
+
}
|
|
124
|
+
const STATUS_BY_CODE = {
|
|
125
|
+
invalid_request: 400,
|
|
126
|
+
missing_reference: 422,
|
|
127
|
+
not_applicable: 422,
|
|
128
|
+
unresolved_constraint: 422,
|
|
129
|
+
unauthorized: 403,
|
|
130
|
+
not_found: 404,
|
|
131
|
+
conflict: 409,
|
|
132
|
+
integrity_mismatch: 409,
|
|
133
|
+
storage_io_error: 503,
|
|
134
|
+
};
|
|
135
|
+
function snapshotError(error) {
|
|
136
|
+
if (error instanceof JudgmentProblemSnapshotError) {
|
|
137
|
+
return { status: STATUS_BY_CODE[error.code] ?? 500, code: error.code, message: error.message };
|
|
138
|
+
}
|
|
139
|
+
return { status: 500, code: 'internal_error', message: 'Judgment problem snapshot request failed' };
|
|
140
|
+
}
|
|
141
|
+
function assertContext(context) {
|
|
142
|
+
if (typeof context.principal !== 'string' || context.principal.trim().length === 0 || context.principal.length > 512) {
|
|
143
|
+
throw new HttpInputError('Authenticated principal is required');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function mutationVerified(options, request, context) {
|
|
147
|
+
if (typeof context.verifiedMutationOrigin === 'string' && context.verifiedMutationOrigin.trim().length > 0)
|
|
148
|
+
return true;
|
|
149
|
+
if (!options.verifyMutationRequest)
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
return (await options.verifyMutationRequest({ request, context })) === true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Saves and reads immutable judgment problem snapshots over HTTP. The host
|
|
160
|
+
* authenticates, binds the principal, and supplies the reference resolution;
|
|
161
|
+
* this handler never takes identity from the request body.
|
|
162
|
+
*/
|
|
163
|
+
export function createJudgmentProblemSnapshotHttpHandler(options) {
|
|
164
|
+
if (!options || typeof options !== 'object')
|
|
165
|
+
throw new TypeError('options are required');
|
|
166
|
+
if (typeof options.root !== 'string' || options.root.trim().length === 0)
|
|
167
|
+
throw new TypeError('root must be a non-empty path');
|
|
168
|
+
if (typeof options.providerFactory !== 'function')
|
|
169
|
+
throw new TypeError('providerFactory is required');
|
|
170
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
171
|
+
const bodyLimitBytes = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
|
|
172
|
+
if (!Number.isInteger(bodyLimitBytes) || bodyLimitBytes <= 0)
|
|
173
|
+
throw new TypeError('bodyLimitBytes must be a positive integer');
|
|
174
|
+
return async (request, response, context) => {
|
|
175
|
+
const route = matchRoute(requestPath(request), basePath);
|
|
176
|
+
if (!route)
|
|
177
|
+
return false;
|
|
178
|
+
if (!context) {
|
|
179
|
+
writeError(response, 401, 'unauthorized', 'Authenticated judgment problem snapshot context is required');
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
assertContext(context);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
writeError(response, 401, 'unauthorized', error instanceof Error ? error.message : 'Authenticated principal is required');
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (route.operation === 'unknown') {
|
|
190
|
+
writeError(response, 404, 'not_found', 'Judgment problem snapshot route was not found');
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
194
|
+
const allowed = route.operation === 'save' ? 'POST' : 'GET';
|
|
195
|
+
if (method !== allowed) {
|
|
196
|
+
writeError(response, 405, 'method_not_allowed', 'Method is not allowed for this judgment problem snapshot route', { Allow: allowed });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (route.operation === 'read') {
|
|
200
|
+
if (!route.snapshotId || !snapshotIdPattern.test(route.snapshotId)) {
|
|
201
|
+
writeError(response, 400, 'invalid_request', 'snapshot_id must be sha256:<64 lowercase hex>');
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const providers = await options.providerFactory(context);
|
|
206
|
+
const snapshot = await loadJudgmentProblemSnapshot({
|
|
207
|
+
root: options.root,
|
|
208
|
+
snapshot_id: route.snapshotId,
|
|
209
|
+
access: { principal: context.principal },
|
|
210
|
+
reference_resolution: 'current',
|
|
211
|
+
referenceProvider: providers.referenceProvider,
|
|
212
|
+
...(providers.accessProvider ? { accessProvider: providers.accessProvider } : {}),
|
|
213
|
+
});
|
|
214
|
+
writeJson(response, 200, { action: 'read', snapshotId: route.snapshotId, result: snapshot });
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const failure = snapshotError(error);
|
|
218
|
+
writeError(response, failure.status, failure.code, failure.message);
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
let snapshot;
|
|
223
|
+
try {
|
|
224
|
+
snapshot = parseSaveBody(await readJsonBody(request, bodyLimitBytes));
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error instanceof BodyLimitError)
|
|
228
|
+
writeError(response, 413, 'body_too_large', error.message);
|
|
229
|
+
else
|
|
230
|
+
writeError(response, 400, 'invalid_request', error instanceof Error ? error.message : 'Request body is invalid');
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (!await mutationVerified(options, request, context)) {
|
|
234
|
+
writeError(response, 403, 'mutation_origin_unverified', 'Mutation origin could not be verified');
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const providers = await options.providerFactory(context);
|
|
239
|
+
const receipt = await saveJudgmentProblemSnapshot({
|
|
240
|
+
root: options.root,
|
|
241
|
+
snapshot,
|
|
242
|
+
access: { principal: context.principal },
|
|
243
|
+
referenceProvider: providers.referenceProvider,
|
|
244
|
+
...(providers.accessProvider ? { accessProvider: providers.accessProvider } : {}),
|
|
245
|
+
});
|
|
246
|
+
writeJson(response, receipt.status === 'created' ? 201 : 200, { action: 'save', result: receipt });
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
const failure = snapshotError(error);
|
|
250
|
+
writeError(response, failure.status, failure.code, failure.message);
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
export const createJudgmentProblemSnapshotRoute = createJudgmentProblemSnapshotHttpHandler;
|
|
@@ -14,12 +14,27 @@ export interface JudgmentValueProofArtifactRef {
|
|
|
14
14
|
}
|
|
15
15
|
/** Which part of the organization's judgment a basis came from. Absent means not recorded. */
|
|
16
16
|
export type JudgmentValueProofBasisLayer = 'philosophy' | 'objective' | 'world_model' | 'method' | 'constraint' | 'other';
|
|
17
|
+
/**
|
|
18
|
+
* A source that the local Web host can read and open. This is deliberately
|
|
19
|
+
* not a URL: the host owns the reader and keeps navigation same-origin.
|
|
20
|
+
*/
|
|
21
|
+
export type JudgmentValueProofBasisSourceKind = 'local_graph';
|
|
22
|
+
export type JudgmentValueProofBasisSourceEntityType = 'person' | 'org' | 'project' | 'decision';
|
|
23
|
+
export interface JudgmentValueProofBasisSource {
|
|
24
|
+
kind: JudgmentValueProofBasisSourceKind;
|
|
25
|
+
entity_id: string;
|
|
26
|
+
entity_type: JudgmentValueProofBasisSourceEntityType;
|
|
27
|
+
version?: string | null;
|
|
28
|
+
digest?: string | null;
|
|
29
|
+
}
|
|
17
30
|
export interface JudgmentValueProofBasis {
|
|
18
31
|
entity_id: string;
|
|
19
32
|
application: string;
|
|
20
33
|
layer?: JudgmentValueProofBasisLayer;
|
|
21
34
|
/** Version of the referenced entity used for this judgment. */
|
|
22
35
|
version?: string | null;
|
|
36
|
+
/** Optional, typed read contract for a source-bearing proof. */
|
|
37
|
+
source?: JudgmentValueProofBasisSource | null;
|
|
23
38
|
}
|
|
24
39
|
export interface JudgmentValueProofInheritanceSource {
|
|
25
40
|
kind: 'judgment' | 'method' | 'decision' | 'other';
|
|
@@ -81,6 +81,23 @@ export function validateJudgmentValueProof(proof) {
|
|
|
81
81
|
if (entry.layer !== undefined && !layers.includes(entry.layer)) {
|
|
82
82
|
throw new TypeError(`unsupported decision.basis[].layer: ${String(entry.layer)}`);
|
|
83
83
|
}
|
|
84
|
+
const source = entry.source;
|
|
85
|
+
if (source === undefined || source === null)
|
|
86
|
+
continue;
|
|
87
|
+
if (source.kind !== 'local_graph') {
|
|
88
|
+
throw new TypeError('decision.basis[].source.kind must be local_graph');
|
|
89
|
+
}
|
|
90
|
+
requiredText(source.entity_id, 'decision.basis[].source.entity_id');
|
|
91
|
+
if (source.entity_id !== entry.entity_id) {
|
|
92
|
+
throw new TypeError('decision.basis[].source.entity_id must match entity_id');
|
|
93
|
+
}
|
|
94
|
+
if (!['person', 'org', 'project', 'decision'].includes(source.entity_type)) {
|
|
95
|
+
throw new TypeError(`unsupported decision.basis[].source.entity_type: ${String(source.entity_type)}`);
|
|
96
|
+
}
|
|
97
|
+
if (source.version !== undefined && source.version !== null)
|
|
98
|
+
requiredText(source.version, 'decision.basis[].source.version');
|
|
99
|
+
if (source.digest !== undefined && source.digest !== null)
|
|
100
|
+
requiredText(source.digest, 'decision.basis[].source.digest');
|
|
84
101
|
}
|
|
85
102
|
const kind = proof.decision.judgment_kind;
|
|
86
103
|
if (kind !== undefined && kind !== null) {
|
|
@@ -3,7 +3,7 @@ export declare const KNOWLEDGE_PROMOTION_CONTRACT_VERSION: "knowledge_promotion.
|
|
|
3
3
|
export declare const NORMALIZED_PROMOTION_SCHEMA_VERSION: "personal_knowledge_normalized.v1";
|
|
4
4
|
export type PromotionApprovalKind = 'personal_share' | 'organization_candidate';
|
|
5
5
|
export type PromotionSourceKind = 'personal_knowledge_v1' | 'organization_candidate';
|
|
6
|
-
export type PromotionStatus = 'pending_owner_approval' | 'pending_org_review' | 'owner_rejected' | 'org_rejected' | 'org_accepted' | 'source_stale';
|
|
6
|
+
export type PromotionStatus = 'pending_owner_approval' | 'pending_org_review' | 'owner_rejected' | 'org_rejected' | 'org_accepted' | 'source_stale' | 'source_withdrawn';
|
|
7
7
|
export type PromotionDecision = 'approve' | 'reject';
|
|
8
8
|
export type PromotionReviewAction = 'request' | 'owner_consent' | 'organization_review';
|
|
9
9
|
export declare class KnowledgePromotionError extends Error {
|
|
@@ -72,8 +72,8 @@ export interface PromotionSourceRef {
|
|
|
72
72
|
evidence_hash: string;
|
|
73
73
|
}
|
|
74
74
|
export interface PromotionDecisionRecord {
|
|
75
|
-
action: 'owner_consent' | 'organization_review';
|
|
76
|
-
decision: PromotionDecision;
|
|
75
|
+
action: 'owner_consent' | 'organization_review' | 'source_withdrawal';
|
|
76
|
+
decision: PromotionDecision | 'withdraw';
|
|
77
77
|
actor_person_id: string;
|
|
78
78
|
decided_at: string;
|
|
79
79
|
receipt_id: string | null;
|
|
@@ -181,6 +181,36 @@ export interface KnowledgePromotionLineage {
|
|
|
181
181
|
};
|
|
182
182
|
created_at: string;
|
|
183
183
|
}
|
|
184
|
+
/** Append-only record of a share cancelled because the owner retracted the source memory. */
|
|
185
|
+
export interface KnowledgePromotionWithdrawal {
|
|
186
|
+
withdrawal_id: string;
|
|
187
|
+
contract_version: typeof KNOWLEDGE_PROMOTION_CONTRACT_VERSION;
|
|
188
|
+
approval_kind: PromotionApprovalKind;
|
|
189
|
+
promotion_request_id: string;
|
|
190
|
+
source: PromotionSourceRef;
|
|
191
|
+
owner_person_id: string | null;
|
|
192
|
+
organization_id: string;
|
|
193
|
+
project_code: string;
|
|
194
|
+
previous_status: PromotionStatus;
|
|
195
|
+
organization_event_id: string | null;
|
|
196
|
+
graph_entity_id: string | null;
|
|
197
|
+
graph_withdrawn: boolean;
|
|
198
|
+
graph_outcome: 'not_promoted' | 'retracted' | 'projection_replaced';
|
|
199
|
+
withdrawn_by_person_id: string;
|
|
200
|
+
reason: string | null;
|
|
201
|
+
created_at: string;
|
|
202
|
+
}
|
|
203
|
+
export interface PromotedEntityWithdrawalInput {
|
|
204
|
+
request_id: string;
|
|
205
|
+
graph_entity_id: string;
|
|
206
|
+
organization_event_id: string | null;
|
|
207
|
+
organization_id: string;
|
|
208
|
+
project_code: string;
|
|
209
|
+
normalized_payload_hash: string;
|
|
210
|
+
withdrawal_id: string;
|
|
211
|
+
withdrawn_at: string;
|
|
212
|
+
reason: string | null;
|
|
213
|
+
}
|
|
184
214
|
export interface KnowledgePromotionStore {
|
|
185
215
|
transaction<T>(work: (tx: unknown) => Promise<T>, options?: {
|
|
186
216
|
principal: KnowledgePromotionPrincipal;
|
|
@@ -202,6 +232,9 @@ export interface KnowledgePromotionStore {
|
|
|
202
232
|
organization_id: string;
|
|
203
233
|
project_code: string;
|
|
204
234
|
}, ctx: PromotionContext): Promise<void>;
|
|
235
|
+
/** Requests of one source visible to the caller, locked so a concurrent decision is ordered with the withdrawal. */
|
|
236
|
+
listRequestsBySource?(sourceKind: PromotionSourceKind, sourceId: string, ctx: PromotionContext): Promise<KnowledgePromotionRequest[]>;
|
|
237
|
+
recordWithdrawal?(withdrawal: KnowledgePromotionWithdrawal, ctx: PromotionContext): Promise<void>;
|
|
205
238
|
}
|
|
206
239
|
export interface KnowledgePromotionSources {
|
|
207
240
|
readPersonalKnowledgeEvent?(eventId: string, ctx: PromotionContext): Promise<PersonalKnowledgeSourceRecord | null>;
|
|
@@ -249,6 +282,14 @@ export interface KnowledgePromotionGraphWriter {
|
|
|
249
282
|
commitNormalizedPromotion(mutation: NormalizedGraphMutation, ctx: PromotionContext): Promise<{
|
|
250
283
|
id: string;
|
|
251
284
|
} | null>;
|
|
285
|
+
/**
|
|
286
|
+
* Mark the fact promoted by the request as retracted and out of retrieval. `projection_replaced` means
|
|
287
|
+
* another write has replaced the fact since, so it was left as it is.
|
|
288
|
+
*/
|
|
289
|
+
withdrawPromotedEntity?(input: PromotedEntityWithdrawalInput, ctx: PromotionContext): Promise<{
|
|
290
|
+
id: string;
|
|
291
|
+
outcome: 'retracted' | 'projection_replaced';
|
|
292
|
+
} | null>;
|
|
252
293
|
}
|
|
253
294
|
export interface KnowledgePromotionPorts {
|
|
254
295
|
store: KnowledgePromotionStore;
|
|
@@ -400,6 +441,23 @@ export declare class KnowledgePromotionService {
|
|
|
400
441
|
principal: KnowledgePromotionPrincipal;
|
|
401
442
|
authority?: KnowledgePromotionAuthority | null;
|
|
402
443
|
}): Promise<KnowledgePromotionRequest>;
|
|
444
|
+
/**
|
|
445
|
+
* D4: the owner retracted a Personal Knowledge v1 memory. Cancel its shares that wait for a decision and
|
|
446
|
+
* withdraw an already promoted Graph fact from future retrieval. The approval lineage, the organization
|
|
447
|
+
* event, and the receipts stay. Retraction is the owner's right, so the organization review policy is
|
|
448
|
+
* not consulted and lost project access does not block it. Pass `tx` to commit with the retraction itself.
|
|
449
|
+
*/
|
|
450
|
+
withdrawPersonalShares(input: {
|
|
451
|
+
personal_event_id: string;
|
|
452
|
+
reason?: string | null;
|
|
453
|
+
}, context: {
|
|
454
|
+
principal: KnowledgePromotionPrincipal;
|
|
455
|
+
tx?: unknown;
|
|
456
|
+
}): Promise<{
|
|
457
|
+
personal_event_id: string;
|
|
458
|
+
withdrawals: KnowledgePromotionWithdrawal[];
|
|
459
|
+
}>;
|
|
460
|
+
private withdrawRequest;
|
|
403
461
|
private requirePolicy;
|
|
404
462
|
private draftRequest;
|
|
405
463
|
private authorize;
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* task candidate, ...) → organization approval → Graph. It never takes the
|
|
11
11
|
* shape of an owner consent.
|
|
12
12
|
*
|
|
13
|
+
* When the owner retracts the source memory, `withdrawPersonalShares` cancels
|
|
14
|
+
* the shares still waiting for a decision and withdraws an already promoted
|
|
15
|
+
* Graph fact from future retrieval, keeping the approval lineage and audit.
|
|
16
|
+
*
|
|
13
17
|
* This module owns normalization, receipts, evidence, lineage, the Graph
|
|
14
18
|
* mutation, and the state machine. Storage, the source readers, the knowledge
|
|
15
19
|
* event kernel, the Graph writer, and the organization review policy (roles,
|
|
@@ -275,7 +279,8 @@ export function candidateApprovalReceipt(request, reviewerPersonId, reviewedAt)
|
|
|
275
279
|
}).slice(0, 24)}`;
|
|
276
280
|
}
|
|
277
281
|
const DECISIONS = new Set(['approve', 'reject']);
|
|
278
|
-
const TERMINAL = new Set(['owner_rejected', 'org_rejected', 'org_accepted', 'source_stale']);
|
|
282
|
+
const TERMINAL = new Set(['owner_rejected', 'org_rejected', 'org_accepted', 'source_stale', 'source_withdrawn']);
|
|
283
|
+
const WITHDRAWABLE = new Set(['pending_owner_approval', 'pending_org_review', 'org_accepted']);
|
|
279
284
|
function sanitizePreview(value) {
|
|
280
285
|
const text = String(value ?? '').trim().slice(0, 2000);
|
|
281
286
|
if (!text || SECRET_OR_PRIVATE.test(text))
|
|
@@ -317,6 +322,11 @@ function requireHuman(principal) {
|
|
|
317
322
|
function actorOf(principal) {
|
|
318
323
|
return principal.actorPersonId || principal.personId;
|
|
319
324
|
}
|
|
325
|
+
function rejectWithdrawn(request) {
|
|
326
|
+
if (request.status === 'source_withdrawn') {
|
|
327
|
+
throw new KnowledgePromotionError('knowledge_promotion_source_withdrawn', 409, { request_id: request.request_id });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
320
330
|
function requireProjectAccess(principal, projectCode) {
|
|
321
331
|
if (!Array.isArray(principal.projectCodes) || !principal.projectCodes.includes(projectCode)) {
|
|
322
332
|
throw new KnowledgePromotionError('knowledge_promotion_project_access_denied', 403);
|
|
@@ -572,6 +582,7 @@ export class KnowledgePromotionService {
|
|
|
572
582
|
throw new KnowledgePromotionError('knowledge_promotion_request_not_found', 404);
|
|
573
583
|
}
|
|
574
584
|
requireProjectAccess(principal, request.project_code);
|
|
585
|
+
rejectWithdrawn(request);
|
|
575
586
|
const normalized = normalizePromotionPayload(request.normalized_payload).normalized;
|
|
576
587
|
await this.authorize(policy, 'owner_consent', request, normalized, principal, context.authority ?? null, ctx);
|
|
577
588
|
await this.claimAuthority(context.authority ?? null, request, 'owner_consent', ctx);
|
|
@@ -629,6 +640,8 @@ export class KnowledgePromotionService {
|
|
|
629
640
|
if (request.approval_kind === 'personal_share' && request.owner_person_id === actor) {
|
|
630
641
|
throw new KnowledgePromotionError('knowledge_promotion_distinct_reviewer_required', 403);
|
|
631
642
|
}
|
|
643
|
+
// The owner retracted the source memory; neither an approval nor a retried decision may pass.
|
|
644
|
+
rejectWithdrawn(request);
|
|
632
645
|
if (request.status === 'pending_owner_approval' || request.status === 'owner_rejected') {
|
|
633
646
|
throw new KnowledgePromotionError('knowledge_promotion_owner_consent_required', 409);
|
|
634
647
|
}
|
|
@@ -739,6 +752,104 @@ export class KnowledgePromotionService {
|
|
|
739
752
|
throw new KnowledgePromotionError('knowledge_promotion_source_stale', 409, { request_id: requestId });
|
|
740
753
|
return result;
|
|
741
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* D4: the owner retracted a Personal Knowledge v1 memory. Cancel its shares that wait for a decision and
|
|
757
|
+
* withdraw an already promoted Graph fact from future retrieval. The approval lineage, the organization
|
|
758
|
+
* event, and the receipts stay. Retraction is the owner's right, so the organization review policy is
|
|
759
|
+
* not consulted and lost project access does not block it. Pass `tx` to commit with the retraction itself.
|
|
760
|
+
*/
|
|
761
|
+
async withdrawPersonalShares(input, context) {
|
|
762
|
+
const principal = requireHuman(context.principal);
|
|
763
|
+
const read = this.ports.sources.readPersonalKnowledgeEvent;
|
|
764
|
+
if (typeof read !== 'function')
|
|
765
|
+
throw new KnowledgePromotionError('knowledge_promotion_personal_source_unavailable', 503);
|
|
766
|
+
const { listRequestsBySource, recordWithdrawal } = this.ports.store;
|
|
767
|
+
if (typeof listRequestsBySource !== 'function' || typeof recordWithdrawal !== 'function') {
|
|
768
|
+
throw new KnowledgePromotionError('knowledge_promotion_withdrawal_unavailable', 503);
|
|
769
|
+
}
|
|
770
|
+
const eventId = requireText(input?.personal_event_id, 'knowledge_promotion_source_id_required');
|
|
771
|
+
const reason = sanitizeReason(input?.reason);
|
|
772
|
+
const work = async (tx) => {
|
|
773
|
+
const ctx = { tx, principal };
|
|
774
|
+
const event = await read.call(this.ports.sources, eventId, ctx);
|
|
775
|
+
if (!event || event.owner_person_id !== principal.personId || event.organization_id !== principal.organizationId) {
|
|
776
|
+
throw new KnowledgePromotionError('knowledge_promotion_source_not_found', 404);
|
|
777
|
+
}
|
|
778
|
+
if (event.active)
|
|
779
|
+
throw new KnowledgePromotionError('knowledge_promotion_source_still_active', 409);
|
|
780
|
+
const requests = (await listRequestsBySource.call(this.ports.store, 'personal_knowledge_v1', eventId, ctx))
|
|
781
|
+
.filter((request) => request.approval_kind === 'personal_share'
|
|
782
|
+
&& request.owner_person_id === principal.personId
|
|
783
|
+
&& request.organization_id === principal.organizationId
|
|
784
|
+
&& WITHDRAWABLE.has(request.status))
|
|
785
|
+
.sort((left, right) => left.request_id.localeCompare(right.request_id));
|
|
786
|
+
const withdrawals = [];
|
|
787
|
+
for (const request of requests) {
|
|
788
|
+
withdrawals.push(await this.withdrawRequest(request, reason, ctx));
|
|
789
|
+
}
|
|
790
|
+
return { personal_event_id: eventId, withdrawals };
|
|
791
|
+
};
|
|
792
|
+
return context.tx === undefined
|
|
793
|
+
? this.ports.store.transaction(work, { principal })
|
|
794
|
+
: work(context.tx);
|
|
795
|
+
}
|
|
796
|
+
async withdrawRequest(request, reason, ctx) {
|
|
797
|
+
const withdrawnAt = this.now().toISOString();
|
|
798
|
+
const actor = actorOf(ctx.principal);
|
|
799
|
+
const withdrawalId = `kpw_${promotionSha256(`${request.request_id}:${request.source.version}:withdrawal`).slice(0, 24)}`;
|
|
800
|
+
let graphOutcome = 'not_promoted';
|
|
801
|
+
if (request.status === 'org_accepted') {
|
|
802
|
+
const withdraw = this.ports.graph.withdrawPromotedEntity;
|
|
803
|
+
if (typeof withdraw !== 'function')
|
|
804
|
+
throw new KnowledgePromotionError('knowledge_promotion_withdrawal_unavailable', 503);
|
|
805
|
+
const result = await withdraw.call(this.ports.graph, {
|
|
806
|
+
request_id: request.request_id,
|
|
807
|
+
graph_entity_id: requireText(request.graph_entity_id, 'knowledge_promotion_graph_entity_required'),
|
|
808
|
+
organization_event_id: request.organization_event_id,
|
|
809
|
+
organization_id: request.organization_id,
|
|
810
|
+
project_code: request.project_code,
|
|
811
|
+
normalized_payload_hash: request.normalized_payload_hash,
|
|
812
|
+
withdrawal_id: withdrawalId,
|
|
813
|
+
withdrawn_at: withdrawnAt,
|
|
814
|
+
reason,
|
|
815
|
+
}, ctx);
|
|
816
|
+
if (!result?.id || (result.outcome !== 'retracted' && result.outcome !== 'projection_replaced')) {
|
|
817
|
+
throw new KnowledgePromotionError('knowledge_promotion_graph_withdrawal_failed', 409, { request_id: request.request_id });
|
|
818
|
+
}
|
|
819
|
+
graphOutcome = result.outcome;
|
|
820
|
+
}
|
|
821
|
+
const updated = await this.ports.store.updateRequest(request.request_id, {
|
|
822
|
+
status: 'source_withdrawn',
|
|
823
|
+
decisions: [...request.decisions, {
|
|
824
|
+
action: 'source_withdrawal', decision: 'withdraw', actor_person_id: actor, decided_at: withdrawnAt,
|
|
825
|
+
receipt_id: withdrawalId, reason,
|
|
826
|
+
}],
|
|
827
|
+
updated_at: withdrawnAt,
|
|
828
|
+
}, { expectedRevision: request.revision }, ctx);
|
|
829
|
+
if (!updated)
|
|
830
|
+
throw new KnowledgePromotionError('knowledge_promotion_state_conflict', 409);
|
|
831
|
+
const withdrawal = {
|
|
832
|
+
withdrawal_id: withdrawalId,
|
|
833
|
+
contract_version: KNOWLEDGE_PROMOTION_CONTRACT_VERSION,
|
|
834
|
+
approval_kind: request.approval_kind,
|
|
835
|
+
promotion_request_id: request.request_id,
|
|
836
|
+
source: request.source,
|
|
837
|
+
owner_person_id: request.owner_person_id,
|
|
838
|
+
organization_id: request.organization_id,
|
|
839
|
+
project_code: request.project_code,
|
|
840
|
+
previous_status: request.status,
|
|
841
|
+
organization_event_id: request.organization_event_id,
|
|
842
|
+
graph_entity_id: request.graph_entity_id,
|
|
843
|
+
graph_withdrawn: graphOutcome === 'retracted',
|
|
844
|
+
graph_outcome: graphOutcome,
|
|
845
|
+
withdrawn_by_person_id: actor,
|
|
846
|
+
reason,
|
|
847
|
+
created_at: withdrawnAt,
|
|
848
|
+
};
|
|
849
|
+
await this.ports.store.recordWithdrawal
|
|
850
|
+
.call(this.ports.store, withdrawal, ctx);
|
|
851
|
+
return withdrawal;
|
|
852
|
+
}
|
|
742
853
|
requirePolicy() {
|
|
743
854
|
if (!this.ports.reviewPolicy || typeof this.ports.reviewPolicy.authorize !== 'function') {
|
|
744
855
|
throw new KnowledgePromotionError('knowledge_promotion_review_policy_unavailable', 503);
|
package/dist/local-web-host.d.ts
CHANGED
|
@@ -100,10 +100,10 @@ export declare function createLocalObjectiveDefinitionBuilder(now: () => Date):
|
|
|
100
100
|
*/
|
|
101
101
|
export declare function createObjectiveFoundationModule(context: LocalWebModuleContext): LocalWebModule;
|
|
102
102
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
103
|
+
* World Model routes. Reads retain the explicit absence/partial contract;
|
|
104
|
+
* the first-user Web entry writes only a trusted Variable definition and an
|
|
105
|
+
* initial Observation. Models, adoption and corrections stay out of this
|
|
106
|
+
* focused route until their own authority and review contract exists.
|
|
107
107
|
*/
|
|
108
108
|
export declare function createWorldModelReadModule(context: LocalWebModuleContext): LocalWebModule;
|
|
109
109
|
/**
|