@panaversity/ksor 0.0.39 → 0.0.41
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/CHANGELOG.md +965 -0
- package/README.md +29 -9
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +293 -119
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +14 -6
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +11 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
- package/templates/scaffold/system/site/app/global.css +198 -6
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +219 -87
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +109 -23
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- package/templates/scaffold/system/site/lib/page-order.ts +0 -93
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the record module touches the filesystem: find the instance
|
|
3
|
+
* and read the tree into `RecordFiles`. `resolveInstanceDir` is the shared
|
|
4
|
+
* `--instance` resolution build spec §1 names for `build`, `migrate`,
|
|
5
|
+
* `takedown` and `ingest` — the nearest ancestor `instance.md`, or null.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import type { RecordFiles } from "./check";
|
|
12
|
+
import type { ScaffoldStructure } from "./hygiene";
|
|
13
|
+
|
|
14
|
+
const CONTROL_FILES = ["instance.md", ".ksor/governance.yaml", ".ksor/takedowns.yaml"] as const;
|
|
15
|
+
/** Files the operating system writes behind the author's back: ignored, never reported. */
|
|
16
|
+
const OS_JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
17
|
+
|
|
18
|
+
/** The directory holding the nearest `instance.md` at or above `start`, or null when none. */
|
|
19
|
+
export function resolveInstanceDir(start: string): string | null {
|
|
20
|
+
let dir = path.resolve(start);
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (existsSync(path.join(dir, "instance.md"))) return dir;
|
|
23
|
+
const parent = path.dirname(dir);
|
|
24
|
+
if (parent === dir) return null;
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Everything the checker reads, under the record at `root`. Symlinks are not
|
|
31
|
+
* followed — they are reported, and the checker refuses them (a dangling one
|
|
32
|
+
* crashed the old checker with a raw ENOENT before any other problem was
|
|
33
|
+
* reported, review 2026-08-18).
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* What the LOADER always produces, as against the in-memory `RecordFiles` a
|
|
37
|
+
* test may hand-build: `assets` and `symlinks` are optional on that type
|
|
38
|
+
* because a fixture map omits them, and every caller that reads a real tree
|
|
39
|
+
* then had to re-prove they exist. The site's build was the one that noticed
|
|
40
|
+
* (`'record.assets' is possibly 'undefined'`) — its `tsc` runs over the
|
|
41
|
+
* template, which this repo's own typecheck does not.
|
|
42
|
+
*/
|
|
43
|
+
export interface LoadedRecord extends RecordFiles {
|
|
44
|
+
readonly assets: ReadonlyMap<string, Uint8Array>;
|
|
45
|
+
readonly symlinks: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadRecord(root: string): LoadedRecord {
|
|
49
|
+
const files = new Map<string, string>();
|
|
50
|
+
const assets = new Map<string, Uint8Array>();
|
|
51
|
+
const dirs: string[] = [];
|
|
52
|
+
const symlinks: string[] = [];
|
|
53
|
+
for (const rel of CONTROL_FILES) {
|
|
54
|
+
const abs = path.join(root, rel);
|
|
55
|
+
if (existsSync(abs) && statSync(abs).isFile()) files.set(rel, readFileSync(abs, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
const knowledge = path.join(root, "knowledge");
|
|
58
|
+
if (existsSync(knowledge)) walk(knowledge, "knowledge", { files, assets, dirs, symlinks });
|
|
59
|
+
return { files, dirs, assets, symlinks };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Walked {
|
|
63
|
+
readonly files: Map<string, string>;
|
|
64
|
+
readonly assets: Map<string, Uint8Array>;
|
|
65
|
+
readonly dirs: string[];
|
|
66
|
+
readonly symlinks: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function walk(abs: string, rel: string, out: Walked): void {
|
|
70
|
+
for (const entry of readdirSync(abs, { withFileTypes: true }).sort((a, b) =>
|
|
71
|
+
a.name < b.name ? -1 : 1,
|
|
72
|
+
)) {
|
|
73
|
+
if (OS_JUNK.has(entry.name)) continue;
|
|
74
|
+
const childAbs = path.join(abs, entry.name);
|
|
75
|
+
const childRel = `${rel}/${entry.name}`;
|
|
76
|
+
if (entry.isSymbolicLink()) {
|
|
77
|
+
out.symlinks.push(childRel);
|
|
78
|
+
} else if (entry.isDirectory()) {
|
|
79
|
+
out.dirs.push(childRel);
|
|
80
|
+
walk(childAbs, childRel, out);
|
|
81
|
+
} else if (entry.isFile() && /\.(md|yaml)$/.test(entry.name)) {
|
|
82
|
+
out.files.set(childRel, readFileSync(childAbs, "utf8"));
|
|
83
|
+
} else if (entry.isFile()) {
|
|
84
|
+
out.assets.set(childRel, new Uint8Array(readFileSync(childAbs)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SITE_BUILD_DIRS = new Set(["node_modules", ".next", ".source", ".staged-knowledge", "out"]);
|
|
90
|
+
|
|
91
|
+
/** The project around the record, for `checkScaffoldStructure` (the emitted checker's structure rules). */
|
|
92
|
+
export function loadScaffoldStructure(root: string): ScaffoldStructure {
|
|
93
|
+
const claudeMd = path.join(root, "CLAUDE.md");
|
|
94
|
+
const digests = (dir: string): Map<string, string> => {
|
|
95
|
+
const out = new Map<string, string>();
|
|
96
|
+
if (!existsSync(dir)) return out;
|
|
97
|
+
for (const file of filesUnder(dir, new Set())) {
|
|
98
|
+
out.set(
|
|
99
|
+
path.relative(dir, file).split(path.sep).join("/"),
|
|
100
|
+
createHash("sha256").update(readFileSync(file)).digest("hex"),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
};
|
|
105
|
+
const site = path.join(root, "system", "site");
|
|
106
|
+
const siteContentFiles = existsSync(site)
|
|
107
|
+
? filesUnder(site, SITE_BUILD_DIRS)
|
|
108
|
+
.filter((p) => /\.mdx?$/i.test(p))
|
|
109
|
+
.map((p) => path.relative(root, p).split(path.sep).join("/"))
|
|
110
|
+
.sort()
|
|
111
|
+
: [];
|
|
112
|
+
return {
|
|
113
|
+
claudeMd: existsSync(claudeMd) ? readFileSync(claudeMd, "utf8") : null,
|
|
114
|
+
agentsSkills: digests(path.join(root, ".agents", "skills")),
|
|
115
|
+
claudeSkills: digests(path.join(root, ".claude", "skills")),
|
|
116
|
+
siteContentFiles,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function filesUnder(dir: string, skip: ReadonlySet<string>): string[] {
|
|
121
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
122
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
|
123
|
+
.flatMap((entry) => {
|
|
124
|
+
const p = path.join(dir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) return skip.has(entry.name) ? [] : filesUnder(p, skip);
|
|
126
|
+
// lstat, not the dirent: a dangling symlink is neither a file nor a directory to readdir.
|
|
127
|
+
return lstatSync(p).isFile() ? [p] : [];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `build.lock.json` (build spec §2): the committed record of one build —
|
|
3
|
+
* what was published, from which commit, with which toolchain — and the
|
|
4
|
+
* `build_id` every projection stamps. Pure: the CLI gathers texts and git
|
|
5
|
+
* facts, this module decides what the lock says. `build_id` covers everything
|
|
6
|
+
* a projection reads and NOTHING that merely describes the run (`as_of`,
|
|
7
|
+
* `source_commit`, `dirty`), so the same tree with the same toolchain yields
|
|
8
|
+
* the same id unless `as_of` crosses a lifecycle boundary — in which case a
|
|
9
|
+
* different admitted set honestly gets a different id (R21).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
import { overlaps } from "../lib/audience-rule";
|
|
16
|
+
import { admitsLifecycle, type LifecycleStatus } from "../lib/lifecycle-rule";
|
|
17
|
+
import { denies, type Denial } from "./ledger";
|
|
18
|
+
|
|
19
|
+
/** OKF at the commit the record spec pins (record spec §1). */
|
|
20
|
+
export const OKF_PIN: {
|
|
21
|
+
readonly version: "0.2";
|
|
22
|
+
readonly commit: string;
|
|
23
|
+
readonly spec_sha256: string;
|
|
24
|
+
} = {
|
|
25
|
+
version: "0.2",
|
|
26
|
+
commit: "ad30107c31c06aec8a7d5636e0d1058118604e6f",
|
|
27
|
+
spec_sha256: "26aa5da029278939f914e578107242d9607d4f2dc5fe153272b82f9ed1030101",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const LOCK_FORMAT = 1;
|
|
31
|
+
|
|
32
|
+
export type Drafts = "hidden" | "shown";
|
|
33
|
+
|
|
34
|
+
const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
|
|
35
|
+
const viewerList = z.array(z.string().min(1));
|
|
36
|
+
|
|
37
|
+
const lockSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
format: z.literal(LOCK_FORMAT),
|
|
40
|
+
build_id: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
41
|
+
ksor_version: z.string().min(1),
|
|
42
|
+
okf: z.object({ version: z.string(), commit: z.string(), spec_sha256: z.string() }).strict(),
|
|
43
|
+
source_commit: z.string().nullable(),
|
|
44
|
+
dirty: z.boolean(),
|
|
45
|
+
as_of: z.string().min(1),
|
|
46
|
+
drafts: z.enum(["hidden", "shown"]),
|
|
47
|
+
instance_sha256: hex64,
|
|
48
|
+
policy_sha256: hex64,
|
|
49
|
+
ledger_sha256: hex64,
|
|
50
|
+
ledger_entries: z.array(z.object({ id: z.string().min(1), digest: hex64 }).strict()),
|
|
51
|
+
audiences: z
|
|
52
|
+
.object({ registry: z.array(z.string()), viewers: z.record(z.string(), viewerList) })
|
|
53
|
+
.strict(),
|
|
54
|
+
documents: z.array(
|
|
55
|
+
z
|
|
56
|
+
.object({
|
|
57
|
+
path: z.string().min(1),
|
|
58
|
+
sha256: hex64,
|
|
59
|
+
status: z.enum(["draft", "stable", "deprecated"]),
|
|
60
|
+
audience: z.array(z.string()),
|
|
61
|
+
admitted: z.array(z.string()),
|
|
62
|
+
})
|
|
63
|
+
.strict(),
|
|
64
|
+
),
|
|
65
|
+
companions: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
66
|
+
assets: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
67
|
+
indexes: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
70
|
+
|
|
71
|
+
export interface LockDocument {
|
|
72
|
+
/** Bundle-relative, with `.md`. */
|
|
73
|
+
readonly path: string;
|
|
74
|
+
readonly sha256: string;
|
|
75
|
+
readonly status: LifecycleStatus;
|
|
76
|
+
readonly audience: readonly string[];
|
|
77
|
+
/** Canonical viewer names whose machine artefacts contain it at `as_of`, sorted. */
|
|
78
|
+
readonly admitted: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface Lock {
|
|
82
|
+
readonly format: typeof LOCK_FORMAT;
|
|
83
|
+
readonly build_id: string;
|
|
84
|
+
readonly ksor_version: string;
|
|
85
|
+
readonly okf: typeof OKF_PIN;
|
|
86
|
+
readonly source_commit: string | null;
|
|
87
|
+
readonly dirty: boolean;
|
|
88
|
+
readonly as_of: string;
|
|
89
|
+
readonly drafts: Drafts;
|
|
90
|
+
readonly instance_sha256: string;
|
|
91
|
+
readonly policy_sha256: string;
|
|
92
|
+
readonly ledger_sha256: string;
|
|
93
|
+
/** `(id, digest)` per ledger entry, sorted by id — the baseline the next build compares TEXT against. */
|
|
94
|
+
readonly ledger_entries: readonly { readonly id: string; readonly digest: string }[];
|
|
95
|
+
readonly audiences: {
|
|
96
|
+
readonly registry: readonly string[];
|
|
97
|
+
readonly viewers: Readonly<Record<string, readonly string[]>>;
|
|
98
|
+
};
|
|
99
|
+
readonly documents: readonly LockDocument[];
|
|
100
|
+
readonly companions: readonly { readonly path: string; readonly sha256: string }[];
|
|
101
|
+
/**
|
|
102
|
+
* Every non-markdown file of the bundle, by bytes. The site publishes these,
|
|
103
|
+
* so a lock that stopped at the markdown left the images and PDFs — often
|
|
104
|
+
* where a record's substance actually is — outside "what was checked".
|
|
105
|
+
*/
|
|
106
|
+
readonly assets: readonly { readonly path: string; readonly sha256: string }[];
|
|
107
|
+
/**
|
|
108
|
+
* The §8 indexes this build GENERATED, by bytes — the only files in
|
|
109
|
+
* `knowledge/` the build writes rather than reads, and the surface an
|
|
110
|
+
* external reader parses to find anything at all. They belonged to no other
|
|
111
|
+
* section (the checker skips `index.md`, `companions` is the attachment
|
|
112
|
+
* kinds, `assets` is the non-markdown), so the record of what was published
|
|
113
|
+
* stopped short of the file that lists what was published.
|
|
114
|
+
*/
|
|
115
|
+
readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type LockResult =
|
|
119
|
+
| { readonly ok: true; readonly lock: Lock }
|
|
120
|
+
| { readonly ok: false; readonly why: string };
|
|
121
|
+
|
|
122
|
+
export function parseLock(text: string): LockResult {
|
|
123
|
+
let value: unknown;
|
|
124
|
+
try {
|
|
125
|
+
value = JSON.parse(text);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, why: `not JSON: ${String(error).split("\n")[0] ?? ""}` };
|
|
128
|
+
}
|
|
129
|
+
const parsed = lockSchema.safeParse(value);
|
|
130
|
+
if (!parsed.success) {
|
|
131
|
+
const issue = parsed.error.issues[0];
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
why: `\`${issue?.path.map(String).join(".") || "(root)"}\`: ${issue?.message ?? "invalid"}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, lock: parsed.data as Lock };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function sha256Hex(data: string | Uint8Array): string {
|
|
141
|
+
return createHash("sha256")
|
|
142
|
+
.update(typeof data === "string" ? Buffer.from(data, "utf8") : data)
|
|
143
|
+
.digest("hex");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface BuildIdInputs {
|
|
147
|
+
readonly documents: readonly {
|
|
148
|
+
readonly path: string;
|
|
149
|
+
readonly sha256: string;
|
|
150
|
+
readonly admitted: readonly string[];
|
|
151
|
+
}[];
|
|
152
|
+
readonly companions: readonly { readonly path: string; readonly sha256: string }[];
|
|
153
|
+
readonly assets: readonly { readonly path: string; readonly sha256: string }[];
|
|
154
|
+
readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
|
|
155
|
+
readonly instance_sha256: string;
|
|
156
|
+
readonly policy_sha256: string;
|
|
157
|
+
readonly ledger_sha256: string;
|
|
158
|
+
readonly ksor_version: string;
|
|
159
|
+
readonly drafts: Drafts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `sha256:<hex>` over a canonical serialisation of everything a projection reads (build spec §2). */
|
|
163
|
+
export function buildIdOf(inputs: BuildIdInputs): string {
|
|
164
|
+
const canonical = {
|
|
165
|
+
documents: [...inputs.documents]
|
|
166
|
+
.map((d) => [d.path, d.sha256, [...d.admitted].sort()])
|
|
167
|
+
.sort((a, b) => compare(String(a[0]), String(b[0]))),
|
|
168
|
+
companions: [...inputs.companions]
|
|
169
|
+
.map((c) => [c.path, c.sha256])
|
|
170
|
+
.sort((a, b) => compare(a[0] ?? "", b[0] ?? "")),
|
|
171
|
+
assets: [...inputs.assets]
|
|
172
|
+
.map((a) => [a.path, a.sha256])
|
|
173
|
+
.sort((a, b) => compare(a[0] ?? "", b[0] ?? "")),
|
|
174
|
+
// The index bytes are a pure function of inputs already hashed here, so
|
|
175
|
+
// including them cannot change an id twice — it states directly what the
|
|
176
|
+
// rest of this object only implies, and a generator whose output moved
|
|
177
|
+
// without its inputs moving goes red rather than publishing quietly under
|
|
178
|
+
// an id that says nothing changed.
|
|
179
|
+
indexes: [...inputs.indexes]
|
|
180
|
+
.map((i) => [i.path, i.sha256])
|
|
181
|
+
.sort((a, b) => compare(a[0] ?? "", b[0] ?? "")),
|
|
182
|
+
instance_sha256: inputs.instance_sha256,
|
|
183
|
+
policy_sha256: inputs.policy_sha256,
|
|
184
|
+
ledger_sha256: inputs.ledger_sha256,
|
|
185
|
+
ksor_version: inputs.ksor_version,
|
|
186
|
+
drafts: inputs.drafts,
|
|
187
|
+
};
|
|
188
|
+
return `sha256:${sha256Hex(JSON.stringify(canonical))}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The canonical viewer lists: `public` alone, and `[public, X]` for each registered audience. */
|
|
192
|
+
export function canonicalViewers(audiences: readonly string[]): Record<string, string[]> {
|
|
193
|
+
const viewers: Record<string, string[]> = { public: ["public"] };
|
|
194
|
+
for (const a of audiences) viewers[a] = ["public", a];
|
|
195
|
+
return viewers;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface AdmissionConcept {
|
|
199
|
+
/** Bundle-relative id (path without `.md`). */
|
|
200
|
+
readonly id: string;
|
|
201
|
+
readonly status: LifecycleStatus;
|
|
202
|
+
readonly effectiveFrom: number | null;
|
|
203
|
+
readonly staleAfter: number | null;
|
|
204
|
+
readonly audience: readonly string[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The viewer names whose MACHINE artefacts contain the concept at `asOf`:
|
|
209
|
+
* stable, effective, unexpired, not denied by an in-force ledger entry, and
|
|
210
|
+
* audience-overlapping. Drafts are never on a machine surface, so the drafts
|
|
211
|
+
* switch does not enter here. Sorted, so the lock is stable.
|
|
212
|
+
*/
|
|
213
|
+
export function admittedViewersOf(
|
|
214
|
+
concept: AdmissionConcept,
|
|
215
|
+
viewers: Readonly<Record<string, readonly string[]>>,
|
|
216
|
+
asOf: number,
|
|
217
|
+
inForceDenials: readonly Denial[],
|
|
218
|
+
): string[] {
|
|
219
|
+
if (!admitsLifecycle(concept, "machine", asOf, "hidden")) return [];
|
|
220
|
+
if (denies(inForceDenials, concept.id)) return [];
|
|
221
|
+
return Object.entries(viewers)
|
|
222
|
+
.filter(([, list]) => overlaps(list, concept.audience))
|
|
223
|
+
.map(([name]) => name)
|
|
224
|
+
.sort();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface LockInput {
|
|
228
|
+
readonly ksorVersion: string;
|
|
229
|
+
readonly sourceCommit: string | null;
|
|
230
|
+
readonly dirty: boolean;
|
|
231
|
+
/** Epoch ms. */
|
|
232
|
+
readonly asOf: number;
|
|
233
|
+
readonly drafts: Drafts;
|
|
234
|
+
readonly instanceText: string;
|
|
235
|
+
readonly policyText: string;
|
|
236
|
+
/** Null when the ledger file does not exist. */
|
|
237
|
+
readonly ledgerText: string | null;
|
|
238
|
+
readonly ledgerEntries: readonly { readonly id: string; readonly digest: string }[];
|
|
239
|
+
/** Registered audiences, from the policy. */
|
|
240
|
+
readonly audiences: readonly string[];
|
|
241
|
+
readonly concepts: readonly (AdmissionConcept & { readonly text: string })[];
|
|
242
|
+
readonly companions: readonly { readonly path: string; readonly text: string }[];
|
|
243
|
+
/** Bundle-relative path → raw bytes, for every non-markdown file of the record. */
|
|
244
|
+
readonly assets: readonly { readonly path: string; readonly bytes: Uint8Array }[];
|
|
245
|
+
/** Bundle-relative path → the §8 index text this build generated (`index.md`, `policies/index.md`). */
|
|
246
|
+
readonly indexes: readonly { readonly path: string; readonly text: string }[];
|
|
247
|
+
readonly denials: readonly Denial[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function composeLock(input: LockInput): Lock {
|
|
251
|
+
const viewers = canonicalViewers(input.audiences);
|
|
252
|
+
const documents = [...input.concepts]
|
|
253
|
+
.sort((a, b) => compare(a.id, b.id))
|
|
254
|
+
.map((c) => ({
|
|
255
|
+
path: `${c.id}.md`,
|
|
256
|
+
sha256: sha256Hex(c.text),
|
|
257
|
+
status: c.status,
|
|
258
|
+
audience: [...c.audience],
|
|
259
|
+
admitted: admittedViewersOf(c, viewers, input.asOf, input.denials),
|
|
260
|
+
}));
|
|
261
|
+
const companions = [...input.companions]
|
|
262
|
+
.sort((a, b) => compare(a.path, b.path))
|
|
263
|
+
.map((c) => ({ path: c.path, sha256: sha256Hex(c.text) }));
|
|
264
|
+
const assets = [...input.assets]
|
|
265
|
+
.sort((a, b) => compare(a.path, b.path))
|
|
266
|
+
.map((a) => ({ path: a.path, sha256: sha256Hex(a.bytes) }));
|
|
267
|
+
const indexes = [...input.indexes]
|
|
268
|
+
.sort((a, b) => compare(a.path, b.path))
|
|
269
|
+
.map((i) => ({ path: i.path, sha256: sha256Hex(i.text) }));
|
|
270
|
+
const instance_sha256 = sha256Hex(input.instanceText);
|
|
271
|
+
const policy_sha256 = sha256Hex(input.policyText);
|
|
272
|
+
const ledger_sha256 = sha256Hex(input.ledgerText ?? "");
|
|
273
|
+
return {
|
|
274
|
+
format: LOCK_FORMAT,
|
|
275
|
+
build_id: buildIdOf({
|
|
276
|
+
documents,
|
|
277
|
+
companions,
|
|
278
|
+
assets,
|
|
279
|
+
indexes,
|
|
280
|
+
instance_sha256,
|
|
281
|
+
policy_sha256,
|
|
282
|
+
ledger_sha256,
|
|
283
|
+
ksor_version: input.ksorVersion,
|
|
284
|
+
drafts: input.drafts,
|
|
285
|
+
}),
|
|
286
|
+
ksor_version: input.ksorVersion,
|
|
287
|
+
okf: OKF_PIN,
|
|
288
|
+
source_commit: input.sourceCommit,
|
|
289
|
+
dirty: input.dirty,
|
|
290
|
+
as_of: new Date(input.asOf).toISOString(),
|
|
291
|
+
drafts: input.drafts,
|
|
292
|
+
instance_sha256,
|
|
293
|
+
policy_sha256,
|
|
294
|
+
ledger_sha256,
|
|
295
|
+
ledger_entries: [...input.ledgerEntries].sort((a, b) => compare(a.id, b.id)),
|
|
296
|
+
audiences: { registry: [...input.audiences].sort(), viewers },
|
|
297
|
+
documents,
|
|
298
|
+
companions,
|
|
299
|
+
assets,
|
|
300
|
+
indexes,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function compare(a: string, b: string): number {
|
|
305
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
306
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean" for a closed key set — ONE implementation.
|
|
3
|
+
*
|
|
4
|
+
* Two closed key sets refuse an unknown key and offer the nearest allowed one:
|
|
5
|
+
* the concept's profile keys and the Governance Policy's. Each carried its own
|
|
6
|
+
* byte-identical Levenshtein loop, in sibling modules of one directory, so
|
|
7
|
+
* tuning one (a different cap, transpositions) would have moved one refusal
|
|
8
|
+
* family and not the other with nothing red.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Edit distance. Only a NEAR miss earns a refusal, so a genuine extension key is kept. */
|
|
12
|
+
export function distance(a: string, b: string): number {
|
|
13
|
+
let previous = [...Array(b.length + 1).keys()];
|
|
14
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
15
|
+
const row = [i];
|
|
16
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
17
|
+
row[j] = Math.min(
|
|
18
|
+
(previous[j] ?? 0) + 1,
|
|
19
|
+
(row[j - 1] ?? 0) + 1,
|
|
20
|
+
(previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
previous = row;
|
|
24
|
+
}
|
|
25
|
+
return previous[b.length] ?? 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The allowed key nearest to `key`, within `max` edits; null when none is close. */
|
|
29
|
+
export function nearest(key: string, allowed: readonly string[], max: number): string | null {
|
|
30
|
+
let best: readonly [string, number] | null = null;
|
|
31
|
+
for (const candidate of allowed) {
|
|
32
|
+
if (candidate === key) return null;
|
|
33
|
+
const d = distance(candidate, key);
|
|
34
|
+
if (d <= max && (best === null || d < best[1])) best = [candidate, d];
|
|
35
|
+
}
|
|
36
|
+
return best === null ? null : best[0];
|
|
37
|
+
}
|