@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,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instance document, `instance.md` format 2 (record spec §3): a
|
|
3
|
+
* profile-shaped document BESIDE the bundle, not a concept — identity is not
|
|
4
|
+
* knowledge, so it carries no `type`, `status` or `ksor.audience`, and the
|
|
5
|
+
* lifecycle table does not apply to it. ONE reader: the checker, `ksor build`
|
|
6
|
+
* and the kernel's `parseInstance` all go through this, so the file cannot
|
|
7
|
+
* mean two things (decision 26). The deployment keys (`database`, `embedding`,
|
|
8
|
+
* `retrieval`, `budgets`, `site`, `mcp_url`, `version`) are passed through as
|
|
9
|
+
* parsed YAML; the kernel binds and validates the ones it consumes.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { splitFrontmatter } from "./frontmatter";
|
|
14
|
+
import type { Refusal } from "./refusal";
|
|
15
|
+
|
|
16
|
+
/** Keys that left the instance for the policy (audiences) or moved under `toolchain` (ksor). */
|
|
17
|
+
export const MOVED_INSTANCE_KEYS = ["audiences", "default_visibility", "ksor"] as const;
|
|
18
|
+
|
|
19
|
+
/** The closed key set: an unknown top-level key is refused, never ignored (a misspelled `retrieval:` would silently turn the abstention gate off). */
|
|
20
|
+
export const INSTANCE_KEYS = [
|
|
21
|
+
"format",
|
|
22
|
+
"name",
|
|
23
|
+
"title",
|
|
24
|
+
"description",
|
|
25
|
+
"toolchain",
|
|
26
|
+
"database",
|
|
27
|
+
"embedding",
|
|
28
|
+
"retrieval",
|
|
29
|
+
"budgets",
|
|
30
|
+
"site",
|
|
31
|
+
"mcp_url",
|
|
32
|
+
"version",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The closed key set INSIDE each group. A group is where a silently-ignored
|
|
37
|
+
* key does the most damage: a misspelled `vector_flor:` leaves the abstention
|
|
38
|
+
* gate off while the owner believes it is on (found live 2026-08-20). The
|
|
39
|
+
* kernel's `groupSchemas` is the authority for these names and
|
|
40
|
+
* `instance-keys-drift.test.ts` holds this map to it.
|
|
41
|
+
*/
|
|
42
|
+
export const NESTED_INSTANCE_KEYS: Readonly<Record<string, readonly string[]>> = {
|
|
43
|
+
toolchain: ["requires", "scaffolded"],
|
|
44
|
+
database: ["dsn_env", "tenant_id"],
|
|
45
|
+
embedding: ["provider", "model", "dim"],
|
|
46
|
+
retrieval: ["text_search_config", "vector_floor", "floor_digest", "keyword_floor"],
|
|
47
|
+
budgets: ["maximum_response_characters"],
|
|
48
|
+
// The site's own group. The kernel does not consume it, but ONE reader now
|
|
49
|
+
// validates the instance for every surface (decision 26), and a key nobody
|
|
50
|
+
// reads is a setting the owner believes is in effect — `site.title` was
|
|
51
|
+
// tolerated while `title` moved to the top level, which is exactly that.
|
|
52
|
+
site: ["url", "governance"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const OPEN_GROUPS: readonly string[] = [];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The map above, read backwards: which block a top-level key would have
|
|
59
|
+
* belonged to.
|
|
60
|
+
*
|
|
61
|
+
* A key of a block written one level out is not an unknown key — it is a known
|
|
62
|
+
* key in the wrong place, and the difference is the whole remedy. `ksor
|
|
63
|
+
* calibrate` printed `vector_floor:` and `floor_digest:` to paste, the paste
|
|
64
|
+
* was refused, and the refusal said to "nest it under the block it belongs to"
|
|
65
|
+
* without ever naming the block. It is `retrieval:`, and this file is where
|
|
66
|
+
* that is written down (first-hour walkthrough, 2026-08-26).
|
|
67
|
+
*/
|
|
68
|
+
const GROUP_OF: ReadonlyMap<string, string> = new Map(
|
|
69
|
+
Object.entries(NESTED_INSTANCE_KEYS).flatMap(([group, keys]) =>
|
|
70
|
+
keys.map((key) => [key, group] as const),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/** The value as it would be written back — so the remedy moves the value, not just the key. */
|
|
75
|
+
function asYaml(value: unknown): string {
|
|
76
|
+
if (typeof value === "string") return value;
|
|
77
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
78
|
+
if (value === null || value === undefined) return "null";
|
|
79
|
+
return JSON.stringify(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
83
|
+
|
|
84
|
+
const schema = z
|
|
85
|
+
.object({
|
|
86
|
+
format: z.literal(2),
|
|
87
|
+
name: z
|
|
88
|
+
.string()
|
|
89
|
+
.regex(
|
|
90
|
+
NAME,
|
|
91
|
+
"the name is the corpus identity every citation carries (ascii lowercase, digits, hyphens)",
|
|
92
|
+
),
|
|
93
|
+
title: z.string().min(1),
|
|
94
|
+
description: z.string().min(1),
|
|
95
|
+
toolchain: z
|
|
96
|
+
.object({ requires: z.string().min(1), scaffolded: z.string().min(1) })
|
|
97
|
+
.loose()
|
|
98
|
+
.optional(),
|
|
99
|
+
database: z.record(z.string(), z.unknown()).optional(),
|
|
100
|
+
embedding: z.record(z.string(), z.unknown()).optional(),
|
|
101
|
+
retrieval: z.record(z.string(), z.unknown()).optional(),
|
|
102
|
+
budgets: z.record(z.string(), z.unknown()).optional(),
|
|
103
|
+
site: z.record(z.string(), z.unknown()).optional(),
|
|
104
|
+
mcp_url: z.string().min(1).optional(),
|
|
105
|
+
version: z.string().min(1).optional(),
|
|
106
|
+
})
|
|
107
|
+
.strict();
|
|
108
|
+
|
|
109
|
+
export interface InstanceDocument {
|
|
110
|
+
readonly name: string;
|
|
111
|
+
readonly title: string;
|
|
112
|
+
readonly description: string;
|
|
113
|
+
readonly toolchain: { readonly requires: string; readonly scaffolded: string } | null;
|
|
114
|
+
readonly database: Readonly<Record<string, unknown>> | null;
|
|
115
|
+
readonly embedding: Readonly<Record<string, unknown>> | null;
|
|
116
|
+
readonly retrieval: Readonly<Record<string, unknown>> | null;
|
|
117
|
+
readonly budgets: Readonly<Record<string, unknown>> | null;
|
|
118
|
+
readonly site: Readonly<Record<string, unknown>> | null;
|
|
119
|
+
readonly mcpUrl: string | null;
|
|
120
|
+
readonly version: string | null;
|
|
121
|
+
/** The MCP server's instructions, in full — the body, edge-trimmed. */
|
|
122
|
+
readonly instructions: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type InstanceResult =
|
|
126
|
+
| { readonly ok: true; readonly instance: InstanceDocument }
|
|
127
|
+
| { readonly ok: false; readonly refusals: readonly Refusal[] };
|
|
128
|
+
|
|
129
|
+
const PATH = "instance.md";
|
|
130
|
+
const FIX =
|
|
131
|
+
"run `ksor migrate --write`, which rewrites the instance to format 2 and moves the audience model into .ksor/governance.yaml";
|
|
132
|
+
|
|
133
|
+
export function parseInstanceDocument(text: string, path: string = PATH): InstanceResult {
|
|
134
|
+
const split = splitFrontmatter(text, path);
|
|
135
|
+
if (!split.ok) return { ok: false, refusals: [split.refusal] };
|
|
136
|
+
const fm = split.frontmatter;
|
|
137
|
+
const refuse = (why: string, fix: string = FIX): InstanceResult => ({
|
|
138
|
+
ok: false,
|
|
139
|
+
refusals: [{ slug: "ksor-instance-format", path, why, fix }],
|
|
140
|
+
});
|
|
141
|
+
if (fm === null) {
|
|
142
|
+
return refuse(
|
|
143
|
+
"instance.md has no frontmatter — the frontmatter is the machine half of the instance definition; without it nothing is declared",
|
|
144
|
+
"open the file with --- on line 1 and close the block with ---",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const moved = MOVED_INSTANCE_KEYS.filter((k) => k in fm);
|
|
148
|
+
if (moved.length > 0) {
|
|
149
|
+
return refuse(
|
|
150
|
+
`\`${moved.join("`, `")}\` no longer live on the instance — audiences and authority live in \`.ksor/governance.yaml\`, and the upgrade stamp under \`toolchain:\``,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (fm["format"] !== 2) {
|
|
154
|
+
return refuse(
|
|
155
|
+
`\`format: ${fm["format"] === undefined ? "(missing)" : String(fm["format"])}\` is not the profile's instance (format 2)`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const unknown = Object.keys(fm).filter((k) => !(INSTANCE_KEYS as readonly string[]).includes(k));
|
|
159
|
+
if (unknown.length > 0) {
|
|
160
|
+
// A key that belongs to a block gets that block by NAME, with the values it
|
|
161
|
+
// already carries — the remedy is a block to write, not a direction to
|
|
162
|
+
// search in. Grouped by the block they belong to, so two keys of one block
|
|
163
|
+
// are one edit.
|
|
164
|
+
const group = unknown.map((k) => GROUP_OF.get(k)).find((g) => g !== undefined);
|
|
165
|
+
if (group !== undefined) {
|
|
166
|
+
const here = unknown.filter((k) => GROUP_OF.get(k) === group);
|
|
167
|
+
const one = here.length === 1;
|
|
168
|
+
return refuse(
|
|
169
|
+
`instance.md declares an unknown top-level key: ${unknown.join(", ")} — ` +
|
|
170
|
+
`${here.map((k) => `\`${k}\``).join(" and ")} ${one ? "is a key" : "are keys"} of the ` +
|
|
171
|
+
`\`${group}:\` block written at the top level, where nothing reads ${one ? "it" : "them"}. ` +
|
|
172
|
+
"The key set is closed so a key never means two things (a misspelled " +
|
|
173
|
+
"`retrieval:` would otherwise turn the abstention gate off silently)",
|
|
174
|
+
`move ${one ? "it" : "them"} under \`${group}:\`, keeping the value${one ? "" : "s"} — ` +
|
|
175
|
+
`the value is the setting, so it moves rather than goes:\n${group}:\n` +
|
|
176
|
+
here.map((k) => ` ${k}: ${asYaml(fm[k])}`).join("\n"),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return refuse(
|
|
180
|
+
`instance.md declares an unknown top-level key: ${unknown.join(", ")} — the key set is closed so a key never means two things (a misspelled \`retrieval:\` would otherwise turn the abstention gate off silently)`,
|
|
181
|
+
`fix the spelling or remove it — the top level reads exactly: ${INSTANCE_KEYS.join(", ")}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
// Every group is closed too, and a group written inline or as a scalar is
|
|
185
|
+
// not read as a group at all — every setting inside it would be dropped.
|
|
186
|
+
const nested: Refusal[] = [];
|
|
187
|
+
for (const [key, allowed] of [
|
|
188
|
+
...Object.entries(NESTED_INSTANCE_KEYS),
|
|
189
|
+
...OPEN_GROUPS.map((k) => [k, null] as const),
|
|
190
|
+
]) {
|
|
191
|
+
const value = fm[key];
|
|
192
|
+
if (value === undefined) continue;
|
|
193
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
194
|
+
nested.push({
|
|
195
|
+
slug: "ksor-instance-format",
|
|
196
|
+
path,
|
|
197
|
+
why: `\`${key}:\` is not a block mapping — a group written inline or as a scalar is not read as a group, so every setting inside it is dropped`,
|
|
198
|
+
fix: `write it as an indented block:\n ${key}:\n <key>: <value>`,
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (allowed === null) continue;
|
|
203
|
+
for (const sub of Object.keys(value as Record<string, unknown>)) {
|
|
204
|
+
if (!allowed.includes(sub)) {
|
|
205
|
+
nested.push({
|
|
206
|
+
slug: "ksor-instance-format",
|
|
207
|
+
path,
|
|
208
|
+
why: `unknown key under \`${key}\`: \`${sub}\``,
|
|
209
|
+
fix: `remove \`${sub}:\` (allowed under ${key}: ${allowed.join(", ")})`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const governance = (fm["site"] as Record<string, unknown> | undefined)?.["governance"];
|
|
215
|
+
if (governance !== undefined && typeof governance !== "boolean") {
|
|
216
|
+
nested.push({
|
|
217
|
+
slug: "ksor-instance-format",
|
|
218
|
+
path,
|
|
219
|
+
why: `\`site.governance\` is ${JSON.stringify(governance)} — it decides whether pages show the governance each document declares, and a value nobody can read is a setting the owner believes is in effect`,
|
|
220
|
+
fix: "write `governance: false` to keep pages plain, or remove the key (the default shows them)",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (nested.length > 0) return { ok: false, refusals: nested };
|
|
224
|
+
|
|
225
|
+
const parsed = schema.safeParse(fm);
|
|
226
|
+
if (!parsed.success) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
refusals: parsed.error.issues.map((issue) => ({
|
|
230
|
+
slug: "ksor-instance-format",
|
|
231
|
+
path,
|
|
232
|
+
why: `\`${issue.path.map(String).join(".") || "(root)"}\`: ${issue.message}`,
|
|
233
|
+
fix: "the instance's shape is record spec §3: `format: 2`, `name`, `title`, `description`, optional `toolchain` and the deployment keys",
|
|
234
|
+
})),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const d = parsed.data;
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
instance: {
|
|
241
|
+
name: d.name,
|
|
242
|
+
title: d.title,
|
|
243
|
+
description: d.description,
|
|
244
|
+
toolchain: d.toolchain
|
|
245
|
+
? { requires: d.toolchain.requires, scaffolded: d.toolchain.scaffolded }
|
|
246
|
+
: null,
|
|
247
|
+
database: d.database ?? null,
|
|
248
|
+
embedding: d.embedding ?? null,
|
|
249
|
+
retrieval: d.retrieval ?? null,
|
|
250
|
+
budgets: d.budgets ?? null,
|
|
251
|
+
site: d.site ?? null,
|
|
252
|
+
mcpUrl: d.mcp_url ?? null,
|
|
253
|
+
version: d.version ?? null,
|
|
254
|
+
instructions: split.body.trim(),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every timestamp in the record is an ISO 8601 instant with an explicit
|
|
3
|
+
* offset (record spec §2.3; upstream OKF made the same move at the pinned
|
|
4
|
+
* commit). A bare date has no instant to compare, and `Date.parse` would
|
|
5
|
+
* silently supply one in the checker's own time zone.
|
|
6
|
+
*
|
|
7
|
+
* The format is half the rule. `Date.parse` does not refuse a day the calendar
|
|
8
|
+
* does not have — it ROLLS it, so `2026-02-30T00:00Z` is accepted as
|
|
9
|
+
* `2026-03-02T00:00:00Z` and `T24:00` as the next day. A governance timestamp
|
|
10
|
+
* decides WHEN a document is in force, so an `effective_from` that means a date
|
|
11
|
+
* nobody wrote is the same silent substitution the offset rule exists to
|
|
12
|
+
* prevent, reached through the value instead of the format (2026-08-25 review).
|
|
13
|
+
*/
|
|
14
|
+
const INSTANT =
|
|
15
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
16
|
+
|
|
17
|
+
/** Epoch milliseconds, or null when the value is not an instant with an offset. */
|
|
18
|
+
export function parseInstant(value: unknown): number | null {
|
|
19
|
+
if (typeof value !== "string") return null;
|
|
20
|
+
const fields = INSTANT.exec(value);
|
|
21
|
+
if (fields === null) return null;
|
|
22
|
+
const ms = Date.parse(value);
|
|
23
|
+
if (Number.isNaN(ms)) return null;
|
|
24
|
+
return rolled(fields) ? null : ms;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Did `Date.parse` move any field the author wrote? Asked by re-reading the
|
|
29
|
+
* date and time of day AT UTC — the offset cannot make a date legal or
|
|
30
|
+
* illegal, and reading them as UTC is the one way to render them back without
|
|
31
|
+
* a second calendar implementation of our own.
|
|
32
|
+
*
|
|
33
|
+
* The fraction is deliberately outside the comparison: it can only truncate
|
|
34
|
+
* (`Date.parse` drops precision below a millisecond), never carry into the
|
|
35
|
+
* second, so it changes no field and refusing it would refuse the six-digit
|
|
36
|
+
* form most languages' `isoformat()` emits.
|
|
37
|
+
*/
|
|
38
|
+
function rolled(fields: RegExpExecArray): boolean {
|
|
39
|
+
const [, year, month, day, hour, minute, second = "00"] = fields;
|
|
40
|
+
const wrote = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
|
41
|
+
const utc = Date.parse(`${wrote}Z`);
|
|
42
|
+
return Number.isNaN(utc) || new Date(utc).toISOString().slice(0, wrote.length) !== wrote;
|
|
43
|
+
}
|