@okfit/profiles 0.9.0 → 0.10.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/Canonical.js ADDED
@@ -0,0 +1,68 @@
1
+ import { body } from "./internal/body.js";
2
+
3
+ //#region src/Canonical.ts
4
+ /** Top-level keys that are bookkeeping, not authored content (spec section 4). */
5
+ const EXCLUDED_KEYS = /* @__PURE__ */ new Set([
6
+ "generated",
7
+ "verified",
8
+ "integrity",
9
+ "stale_after",
10
+ "status",
11
+ "timestamp"
12
+ ]);
13
+ /** `JSON.stringify` omits these in an object and writes `null` for them in an array. */
14
+ const isSkipped = (value) => value === void 0 || typeof value === "function" || typeof value === "symbol";
15
+ /**
16
+ * Canonical JSON: object keys sorted (`Array.prototype.toSorted`, UTF-16 code units)
17
+ * at every depth; arrays keep their order. Byte-for-byte `JSON.stringify` of the
18
+ * sorted value for every JSON-representable input, plus one extension: a `bigint`
19
+ * (what the YAML decoder yields for an integer beyond 2^53, e.g. `ticket:
20
+ * 123456789012345678901`) is written as its bare decimal literal. A literal cannot
21
+ * collide with a string (strings are quoted) and is deterministic, where
22
+ * `JSON.stringify` throws `TypeError` and would defect every caller that hashes.
23
+ */
24
+ const json = (value) => {
25
+ if (typeof value === "bigint") return value.toString();
26
+ if (Array.isArray(value)) return `[${value.map((item) => isSkipped(item) ? "null" : json(item)).join(",")}]`;
27
+ if (value !== null && typeof value === "object") {
28
+ const record = value;
29
+ if (typeof record.toJSON === "function") return json(record.toJSON());
30
+ return `{${Object.keys(record).toSorted().filter((key) => !isSkipped(record[key])).map((key) => `${JSON.stringify(key)}:${json(record[key])}`).join(",")}}`;
31
+ }
32
+ return JSON.stringify(value);
33
+ };
34
+ const stripRendersDigests = (renders) => Array.isArray(renders) ? renders.map((entry) => {
35
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return entry;
36
+ const { body: _body, body_sha256: _legacy, ...rest } = entry;
37
+ return rest;
38
+ }) : renders;
39
+ /**
40
+ * The exact string a content digest hashes: canonical JSON of the authored frontmatter
41
+ * (`raw` minus `Canonical.EXCLUDED_KEYS` and every `renders[].body`) and the
42
+ * normalised body. `raw` is the parsed mapping (YAML 1.2 Core Schema: timestamps are
43
+ * strings), so quoting, flow vs block style, key order and CRLF never change it.
44
+ */
45
+ const contentInput = (raw, source) => {
46
+ const frontmatter = Object.fromEntries(Object.entries(raw).filter(([key]) => !EXCLUDED_KEYS.has(key)).map(([key, value]) => [key, key === "renders" ? stripRendersDigests(value) : value]));
47
+ return json({
48
+ body: body(source),
49
+ frontmatter
50
+ });
51
+ };
52
+ /**
53
+ * Canonical encoding for okfit's content digest.
54
+ *
55
+ * @public
56
+ */
57
+ var Canonical = class {
58
+ constructor() {}
59
+ /** Top-level frontmatter keys excluded from the content digest. */
60
+ static EXCLUDED_KEYS = EXCLUDED_KEYS;
61
+ /** Canonical JSON: sorted keys at every depth, array order kept; a `bigint` is its bare decimal literal. */
62
+ static json = json;
63
+ /** The exact string a content digest hashes. */
64
+ static contentInput = contentInput;
65
+ };
66
+
67
+ //#endregion
68
+ export { Canonical };
package/Derivation.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { body } from "./internal/body.js";
2
+ import { Canonical } from "./Canonical.js";
1
3
  import { GitHistory } from "./GitHistory.js";
2
4
  import { Actor, OkfitConfig } from "@okfit/core";
3
5
  import { Crypto, DateTime, Duration, Effect, FileSystem, Option, Path, Schema } from "effect";
4
6
  import { Git } from "@effected/git";
5
- import { FrontmatterSource } from "@effected/markdown";
6
7
  import * as Hex from "effect/encoding/Hex";
7
8
 
8
9
  //#region src/Derivation.ts
@@ -35,15 +36,40 @@ var AgentActorUnconfiguredError = class extends Schema.TaggedError()("AgentActor
35
36
  return "writer is agent but actors.agent is not configured";
36
37
  }
37
38
  };
39
+ /**
40
+ * `git config okfit.actor` is set but is not a `human:<id>` actor without `@`
41
+ * or glob characters. The value stays on the person's machine; okfit never
42
+ * writes it anywhere.
43
+ *
44
+ * @public
45
+ */
46
+ var ActorConfigInvalidError = class extends Schema.TaggedError()("ActorConfigInvalidError", { value: Schema.String }) {
47
+ get message() {
48
+ return `git config okfit.actor is "${this.value}"; it must be a human:<id> actor with no "@" and no * or ? (for example human:ada)`;
49
+ }
50
+ };
51
+ /**
52
+ * The resolved human is not on a non-empty `[actors].humans` allow-list, so it
53
+ * may not verify or revoke (spec section 8).
54
+ *
55
+ * @public
56
+ */
57
+ var HumanActorNotAllowedError = class extends Schema.TaggedError()("HumanActorNotAllowedError", { actor: Schema.String }) {
58
+ get message() {
59
+ return `${this.actor} is not in [actors].humans, so it cannot verify or revoke; set your id with git config --global okfit.actor human:<id> to a listed id, or add ${this.actor} to [actors].humans`;
60
+ }
61
+ };
38
62
  const HUMAN_PREFIX = "human:";
39
63
  const FALLBACK_STALE_AFTER = Duration.days(90);
40
64
  const decodeActor = Schema.decodeSync(Actor);
41
- const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
42
- const body = (text) => normalise(FrontmatterSource.split(text).body);
43
- const bodyDigest = (text) => Effect.gen(function* () {
44
- const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(body(text)));
65
+ const digestFull = (text) => Effect.gen(function* () {
66
+ const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(text));
45
67
  return Hex.encode(bytes);
46
68
  });
69
+ /** 16-hex okfit digest (spec section 3): the first 64 bits of SHA-256. */
70
+ const digest = (text) => Effect.map(digestFull(text), (hex) => hex.slice(0, 16));
71
+ const bodyDigest = (source) => digest(body(source));
72
+ const contentDigest = (raw, source) => digest(Canonical.contentInput(raw, source));
47
73
  /** The text before the first `@` (the whole value when there is none), or undefined when empty or containing whitespace. */
48
74
  const localPartOf = (email) => {
49
75
  if (email === void 0) return void 0;
@@ -75,8 +101,8 @@ const humanActorId = (identity, humans) => {
75
101
  const slug = slugOf(identity.name);
76
102
  const candidates = [local, slug].filter((candidate) => candidate !== void 0).map((candidate) => candidate.toLowerCase());
77
103
  for (const entry of humans) {
78
- if (!entry.startsWith(HUMAN_PREFIX)) continue;
79
- if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(entry);
104
+ if (!entry.startsWith(HUMAN_PREFIX) || Actor.isPattern(entry)) continue;
105
+ if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(decodeActor(entry));
80
106
  }
81
107
  if (local !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${local}`));
82
108
  if (slug !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${slug}`));
@@ -104,7 +130,7 @@ const uncommitted = (reason) => ({
104
130
  *
105
131
  * @public
106
132
  */
107
- var Derivation = class {
133
+ var Derivation = class Derivation {
108
134
  constructor() {}
109
135
  /**
110
136
  * `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
@@ -112,12 +138,33 @@ var Derivation = class {
112
138
  */
113
139
  static body = body;
114
140
  /**
115
- * Lowercase 64-char hex sha256 of `Derivation.body(text)`, through `effect`'s platform-agnostic
116
- * `Crypto` service (issue #19). Same normalization as `body`, so a frontmatter-only edit or a
117
- * `core.autocrlf` worktree never changes the digest.
141
+ * Full lowercase 64-char hex sha256 of a string. Tests only.
142
+ *
143
+ * @internal
144
+ */
145
+ static digestFull = digestFull;
146
+ /**
147
+ * The okfit digest of a string: the first 16 lowercase hex characters of its SHA-256, through
148
+ * `effect`'s platform-agnostic `Crypto` service.
149
+ */
150
+ static digest = digest;
151
+ /**
152
+ * 16-hex digest of `Derivation.body(source)` (issue #19). Same normalization as `body`, so a
153
+ * frontmatter-only edit or a `core.autocrlf` worktree never changes the digest.
118
154
  */
119
155
  static bodyDigest = bodyDigest;
120
156
  /**
157
+ * The body digest recorded for a generated concept: `integrity.generated.body`, else the legacy
158
+ * `generated.body_sha256` cut to its first 16 hex characters. `undefined` when neither is present.
159
+ * Pure; every "is the stamped body still current" comparison reads through this.
160
+ */
161
+ static recordedBody = (concept) => concept.frontmatter.integrity?.generated?.body ?? concept.frontmatter.generated?.body_sha256?.slice(0, 16);
162
+ /**
163
+ * 16-hex digest of the canonical content input (`Canonical.contentInput`): authored frontmatter
164
+ * plus the normalised body, insensitive to style, key order and CRLF.
165
+ */
166
+ static contentDigest = contentDigest;
167
+ /**
121
168
  * Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
122
169
  * 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
123
170
  * submodules and worktrees work because the question is asked from the file's directory (P-39).
@@ -172,14 +219,15 @@ var Derivation = class {
172
219
  });
173
220
  /**
174
221
  * P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
175
- * and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
176
- * candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
222
+ * and the `user.name` slug. Order, first hit wins: (1) a literal `human:` entry of `humans` (glob entries,
223
+ * those with `*` or `?`, are skipped) whose id equals a candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
177
224
  * The result is branded through core's `Actor` codec and always passes it.
178
225
  */
179
226
  static humanActorId = humanActorId;
180
227
  /**
181
228
  * Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
182
- * (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
229
+ * (P-17). `"human"`: first `Git.configGet(cwd, "okfit.actor")`, which wins when set and must be a
230
+ * `human:<id>` with no `@` or glob ({@link ActorConfigInvalidError} otherwise), returned in the spelling of an exact `[actors].humans` entry that equals it case-insensitively (P-14); else `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
183
231
  * scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
184
232
  * Answers only "who is writing now" (P-40).
185
233
  */
@@ -190,6 +238,13 @@ var Derivation = class {
190
238
  return agent;
191
239
  }
192
240
  const git = yield* Git;
241
+ const chosen = Option.getOrUndefined(yield* git.configGet(options.cwd, "okfit.actor"));
242
+ if (chosen !== void 0) {
243
+ if (!(Schema.is(Actor)(chosen) && Actor.isHuman(chosen) && !chosen.includes("@") && !Actor.isPattern(chosen))) return yield* new ActorConfigInvalidError({ value: chosen });
244
+ const lower = chosen.toLowerCase();
245
+ const spelled = (options.config.actors?.humans ?? []).find((entry) => !Actor.isPattern(entry) && entry.toLowerCase() === lower);
246
+ return spelled === void 0 ? chosen : decodeActor(spelled);
247
+ }
193
248
  const name = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.name"));
194
249
  const email = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.email"));
195
250
  const identity = {
@@ -204,6 +259,22 @@ var Derivation = class {
204
259
  return resolved.value;
205
260
  });
206
261
  /**
262
+ * The human allowed to verify now: {@link Derivation.generatedBy} for a human
263
+ * writer, then the `[actors].humans` allow-list (`Actor.allowed`): empty or
264
+ * absent allows anyone, otherwise the actor must match an entry or this fails
265
+ * {@link HumanActorNotAllowedError}. `okfit verify`, `--revoke` and the LSP
266
+ * verify actions all resolve through this.
267
+ */
268
+ static verifier = Effect.fn("Derivation.verifier")(function* (options) {
269
+ const actor = yield* Derivation.generatedBy({
270
+ writer: "human",
271
+ cwd: options.cwd,
272
+ config: options.config
273
+ });
274
+ if (!Actor.allowed(actor, options.config.actors?.humans ?? [])) return yield* new HumanActorNotAllowedError({ actor });
275
+ return actor;
276
+ });
277
+ /**
207
278
  * P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
208
279
  * 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
209
280
  */
@@ -211,4 +282,4 @@ var Derivation = class {
211
282
  };
212
283
 
213
284
  //#endregion
214
- export { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError };
285
+ export { ActorConfigInvalidError, AgentActorUnconfiguredError, Derivation, HumanActorNotAllowedError, HumanActorUnresolvedError };
package/Provenance.js CHANGED
@@ -12,17 +12,20 @@ const encodeAt = Schema.encodeSync(Timestamp);
12
12
  * like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
13
13
  * are unchanged.
14
14
  *
15
- * Two tiers per concept (issue #19, body-digest design): when
16
- * `generated.body_sha256` is present, drift is decided ENTIRELY by comparing it to
15
+ * Two tiers per concept (issue #19, body-digest design): when a body digest
16
+ * is recorded (`integrity.generated.body`, else the 16-hex prefix of a legacy
17
+ * `generated.body_sha256`, via `Derivation.recordedBody`), drift is decided
18
+ * ENTIRELY by comparing it to
17
19
  * `Derivation.bodyDigest` of the concept's current source -- no git call at
18
20
  * all, so this works for a dirty worktree too (that is the point: an edited
19
21
  * body is caught immediately, before it is even committed) and survives a
20
22
  * squash/rebase merge that only rewrites commit dates, since the body
21
- * itself, and therefore its digest, never changed. When `body_sha256` is
22
- * absent (an un-migrated concept), this falls back to the original
23
+ * itself, and therefore its digest, never changed. When no digest is recorded
24
+ * at all (a concept `okfit sync` has never stamped), this falls back to the original
23
25
  * git-derived date comparison against `Derivation.generatedAt`, preserving
24
26
  * `--skip-provenance`'s meaning for bundles that have not run `okfit sync`
25
- * since this field was introduced.
27
+ * since the digest was introduced. The drift message names the field the
28
+ * recorded value came from, so a legacy concept reads `generated.body_sha256`.
26
29
  *
27
30
  * The tier-2 fallback returns `[]` the moment a `NotARepositoryError`
28
31
  * surfaces: a bundle outside git has nothing to derive for any concept still
@@ -40,8 +43,8 @@ const encodeAt = Schema.encodeSync(Timestamp);
40
43
  * calling at all; the two agree by construction (both skip the walk).
41
44
  *
42
45
  * A third, optional options argument carries `skipGitTier`: when `true`, a
43
- * concept that falls through to tier 2 (no `generated.body_sha256`
44
- * recorded) is skipped entirely -- no `Derivation.generatedAt` call, no
46
+ * concept that falls through to tier 2 (no body digest recorded) is
47
+ * skipped entirely -- no `Derivation.generatedAt` call, no
45
48
  * git spawn at all -- while tier 1 still runs, unaffected, for every
46
49
  * concept that DOES carry a digest. This is a runtime skip, not a
47
50
  * type-level narrowing: the R channel still lists `Git | GitHistory` even
@@ -72,11 +75,13 @@ var Provenance = class {
72
75
  };
73
76
  for (const [, concept] of bundle.concepts) {
74
77
  if (concept.frontmatter.generated === void 0) continue;
75
- const recordedDigest = concept.frontmatter.generated.body_sha256;
78
+ const recordedDigest = Derivation.recordedBody(concept);
76
79
  if (recordedDigest !== void 0) {
77
80
  const currentDigest = yield* Derivation.bodyDigest(concept.document.source);
78
81
  if (currentDigest === recordedDigest) continue;
79
- pushDrift(concept.path, `the body has changed since generated.at was last stamped: generated.body_sha256 is ${recordedDigest}, the current body hashes to ${currentDigest}`, DiagnosticRange.forFrontmatterPath(concept.document, ["generated", "at"]));
82
+ const legacy = concept.frontmatter.generated.body_sha256;
83
+ const recordedAs = concept.frontmatter.integrity?.generated?.body === void 0 && legacy !== void 0 ? `generated.body_sha256 is ${legacy}` : `integrity.generated.body is ${recordedDigest}`;
84
+ pushDrift(concept.path, `the body has changed since generated.at was last stamped: ${recordedAs}, the current body hashes to ${currentDigest}`, DiagnosticRange.forFrontmatterPath(concept.document, ["generated", "at"]));
80
85
  continue;
81
86
  }
82
87
  if (skipGitTier) continue;
package/Publications.js CHANGED
@@ -34,14 +34,16 @@ const rendersOf = (concept) => {
34
34
  const out = [];
35
35
  for (const [index, entry] of value.entries()) {
36
36
  if (typeof entry !== "object" || entry === null || typeof entry.path !== "string") return void 0;
37
- const digest = typeof entry.body_sha256 === "string" && entry.body_sha256 !== "" ? entry.body_sha256 : void 0;
37
+ const current = typeof entry.body === "string" && entry.body !== "" ? entry.body : void 0;
38
+ const legacy = typeof entry.body_sha256 === "string" && entry.body_sha256 !== "" ? entry.body_sha256.slice(0, 16) : void 0;
39
+ const digest = current ?? legacy;
38
40
  out.push(digest === void 0 ? {
39
41
  index,
40
42
  path: entry.path
41
43
  } : {
42
44
  index,
43
45
  path: entry.path,
44
- body_sha256: digest
46
+ body: digest
45
47
  });
46
48
  }
47
49
  return out;
@@ -51,7 +53,7 @@ const compare = (a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.code <
51
53
  * A profiles-owned lint over Publication concepts: `publication-orphan` when a
52
54
  * `surface` or `renders[].path` does not resolve to a bundle concept (or
53
55
  * `renders` is malformed), and `publication-drift` when a rendered source's
54
- * body digest no longer matches the stamped `body_sha256`. Total over
56
+ * body digest no longer matches the stamped `renders[].body`. Total over
55
57
  * severity: returns `[]` before any digest work when both codes are `"off"`.
56
58
  *
57
59
  * @public
@@ -94,7 +96,7 @@ var Publications = class {
94
96
  }
95
97
  const renders = rendersOf(concept);
96
98
  if (renders === void 0) {
97
- if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, "renders must be a list of { path, body_sha256 } entries", ["renders"]);
99
+ if (orphanSeverity !== "off") push(concept, "publication-orphan", orphanSeverity, "renders must be a list of { path, body } entries", ["renders"]);
98
100
  continue;
99
101
  }
100
102
  if (renders.length === 0) {
@@ -117,11 +119,11 @@ var Publications = class {
117
119
  continue;
118
120
  }
119
121
  if (driftSeverity === "off") continue;
120
- if (entry.body_sha256 === void 0) {
122
+ if (entry.body === void 0) {
121
123
  stale.push(`${sourceId} (never stamped)`);
122
124
  continue;
123
125
  }
124
- if ((yield* Derivation.bodyDigest(source.document.source)) !== entry.body_sha256) stale.push(`${sourceId} (changed)`);
126
+ if ((yield* Derivation.bodyDigest(source.document.source)) !== entry.body) stale.push(`${sourceId} (changed)`);
125
127
  }
126
128
  if (stale.length > 0 && driftSeverity !== "off") push(concept, "publication-drift", driftSeverity, `Rendered page ${resource} is stale against: ${stale.join(", ")}; re-render it, then run okfit sync --publication ${id}`, ["renders"]);
127
129
  }
package/README.md CHANGED
@@ -49,7 +49,7 @@ kind = "path"
49
49
 
50
50
  [types.Decision]
51
51
  description = "A choice made, the alternatives rejected, and why."
52
- guidance = "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it."
52
+ guidance = "Edit a Decision when the choice still stands and its text has drifted or is incomplete; the edit voids its verification until a human verifies it again. Deprecate it and write a successor that names it in supersedes only when the choice itself changes and its history matters."
53
53
  require_verified = true
54
54
 
55
55
  [types.Decision.fields.supersedes]
@@ -290,8 +290,8 @@ Each directory carries its own `index.md`. What `okfit init` writes into these f
290
290
  - **`generated.at` is the author date, not the stamp date (P-4).** Walking a path's git history newest-first, the commit that changed the body is the one whose blob first differs from its predecessor's; that commit's *author* date (`%aI`) is `generated.at`. Its committer date, author name, and author email are carried on the result for callers that want a history-based policy later, but `generated.at` itself is never the date of a later frontmatter-only stamp.
291
291
  - **The path log is `@effected/git`'s `Git.log(cwd, { paths: [path], follow: true, firstParentDiffMerges: true })` (P-5, amended P-46, decision 56).** `firstParentDiffMerges` surfaces a conflict-resolving merge whose blob differs from both parents (probed on git 2.54.0), which plain `--follow` can omit and which would otherwise attribute that body change to the wrong commit. `Git.log`'s unconditional `-z` makes `--name-only` emit paths raw — no C-style quoting — so no `core.quotePath` override is needed and a path containing a space, a quote, or a newline round-trips verbatim. The dirty check compares the worktree body against `Git.show(root, "HEAD", path)`, never against the newest log entry. `--follow`'s rename detection can itself stop at a commit that renames a file and rewrites its content enough that git no longer treats it as a continuation of the same history; when that happens, `generated.at` answers that rename commit rather than an older one.
292
292
  - **Uncommitted is the caller's to interpret (P-10).** `Derivation.generatedAt` reports `{ _tag: "uncommitted", reason: "untracked" | "dirty" | "unborn" }` and never substitutes `now`. The recommended policy, followed by the snippet below: omit `generated.at` entirely until the body is committed.
293
- - **The body digest answers "has the body changed", where dates cannot (issue #19).** `Derivation.bodyDigest(text)` is a sha256, hex-encoded, over the same normalised body `Derivation.body` produces (CRLF and lone CR to LF, trailing whitespace at end of text removed), so a `core.autocrlf` worktree and a trailing-newline change hash identically and a frontmatter-only edit does not move the digest at all. It is computed through effect's own `Crypto` service rather than `node:crypto`, so it carries `Crypto.Crypto` in its R channel; `NodeServices.layer` already supplies it wherever this package is used. `okfit sync` records it as `generated.body_sha256` — okfit's own extension key, not an OKF v0.2 field — and `Provenance.lint` compares it. Git cannot make this judgement on its own: the stamp lives in the same blob as the body, so a squash merge (one new commit carrying both) is indistinguishable by history from a body edited without a re-stamp.
294
- - **Human actor resolution (P-13).** Given git identity `{ name?, email? }` and `config.actors.humans`, the first hit wins: (1) a `human:<id>` entry whose id matches the email's local part or the name's slug, case-insensitively (config spelling returned); (2) `human:<local part>`; (3) `human:<slug of name>`; (4) unresolved. The slug lowercases `name`, replaces runs of whitespace and characters outside `[A-Za-z0-9._-]` with `-`, and trims leading and trailing `-`.
293
+ - **The body digest answers "has the body changed", where dates cannot (issue #19).** `Derivation.bodyDigest(text)` is a 16-hex SHA-256 prefix over the same normalised body `Derivation.body` produces (CRLF and lone CR to LF, trailing whitespace at end of text removed), so a `core.autocrlf` worktree and a trailing-newline change hash identically and a frontmatter-only edit does not move the digest at all. It is computed through effect's own `Crypto` service rather than `node:crypto`, so it carries `Crypto.Crypto` in its R channel; `NodeServices.layer` already supplies it wherever this package is used. `okfit sync` records it as `integrity.generated.body` — okfit's own extension key, not an OKF v0.2 field — and `Provenance.lint` compares it. Git cannot make this judgement on its own: the stamp lives in the same blob as the body, so a squash merge (one new commit carrying both) is indistinguishable by history from a body edited without a re-stamp.
294
+ - **Human actor resolution (P-13).** Given git identity `{ name?, email? }` and `config.actors.humans`, the first hit wins: (1) `git config okfit.actor`, which must be a `human:<id>` with no `@` and no `*` or `?` (`ActorConfigInvalidError` otherwise); (2) a literal `human:<id>` entry (glob entries are skipped) whose id matches the email's local part or the name's slug, case-insensitively (config spelling returned); (3) `human:<local part>`; (4) `human:<slug of name>`; (5) unresolved (`HumanActorUnresolvedError`). `Derivation.verifier` runs that resolution and then checks the `[actors].humans` allow-list with core's `Actor.allowed`: an empty or absent list allows anyone, a non-empty one fails with `HumanActorNotAllowedError`, which is what `okfit verify`, `--revoke` and the language server's verify actions surface. okfit never stores an email; the trust signal reports `actor-not-allowed` (a `DriftReason`) for a verifier outside the list. The slug lowercases `name`, replaces runs of whitespace and characters outside `[A-Za-z0-9._-]` with `-`, and trims leading and trailing `-`.
295
295
  - **Identity is read at merged config scope (P-15).** `Derivation.generatedBy` calls `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` with no `scope` option. Environment overrides (`GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `EMAIL`, `user.useConfigOnly`) are out of scope for phase 1.
296
296
  - **`stale_after` is pure addition (P-19).** `Derivation.staleAfter(from, config)` is `from + (config.lifecycle?.default_stale_after ?? OkfitConfig.DEFAULTS.lifecycle.default_stale_after)`, falling back to 90 days. `from` should be `generated.at` when the provenance is `committed`, otherwise the caller's own `now`; re-stamp whenever `generated.at` changes.
297
297
 
@@ -313,7 +313,7 @@ Frontmatter serialisation, the uncommitted policy's enforcement, `now`, layer co
313
313
  lint (`lint.generated_at_drift`, config default `"warn"`), and it runs in
314
314
  two tiers (issue #19).
315
315
 
316
- Tier 1 applies to a concept that records `generated.body_sha256`: the
316
+ Tier 1 applies to a concept that records `integrity.generated.body`: the
317
317
  recorded digest is compared against
318
318
  `Derivation.bodyDigest(concept.document.source)`, a pure comparison over
319
319
  text already in memory with no git call and no filesystem read. Drift here
@@ -58,7 +58,7 @@ const config = {
58
58
  },
59
59
  Decision: {
60
60
  description: "A choice made, the alternatives rejected, and why.",
61
- guidance: "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it.",
61
+ guidance: "Edit a Decision when the choice still stands and its text has drifted or is incomplete; the edit voids its verification until a human verifies it again. Deprecate it and write a successor that names it in supersedes only when the choice itself changes and its history matters.",
62
62
  require_verified: true,
63
63
  fields: { supersedes: {
64
64
  description: "The Decision this one replaces.",
package/Trust.js ADDED
@@ -0,0 +1,156 @@
1
+ import { Derivation } from "./Derivation.js";
2
+ import { Actor, Derive, Diagnostic, DiagnosticRange, OkfitConfig, Timestamp } from "@okfit/core";
3
+ import { DateTime, Effect, Schema } from "effect";
4
+
5
+ //#region src/Trust.ts
6
+ const encodeAt = Schema.encodeSync(Timestamp);
7
+ /**
8
+ * Why a human verification no longer binds: the content changed, the entry has
9
+ * no `integrity.verified` binding, a later human entry is not the bound one, or
10
+ * the bound actor fails a non-empty `[actors].humans` allow-list.
11
+ *
12
+ * @public
13
+ */
14
+ const DriftReason = Schema.Literals([
15
+ "content-changed",
16
+ "unbound",
17
+ "entry-superseded",
18
+ "actor-not-allowed"
19
+ ]);
20
+ /** Only `human:` entries bind; machine entries neither bind nor break a binding. */
21
+ const latestHumanOf = (entries) => entries.filter((entry) => Actor.isHuman(entry.by)).reduce((latest, entry) => latest === void 0 || DateTime.isGreaterThanOrEqualTo(entry.at, latest.at) ? entry : latest, void 0);
22
+ const latestHuman = (concept) => latestHumanOf(concept.frontmatter.verified ?? []);
23
+ const inputOf = (concept, config) => ({
24
+ verified: concept.frontmatter.verified ?? [],
25
+ binding: concept.frontmatter.integrity?.verified,
26
+ generated: concept.frontmatter.generated,
27
+ humans: config.actors?.humans ?? []
28
+ });
29
+ /** The binding when it names the latest human entry; `undefined` when none, unbound or superseded. */
30
+ const boundEntry = (input) => {
31
+ const latest = latestHumanOf(input.verified);
32
+ const binding = input.binding;
33
+ if (latest === void 0 || binding === void 0) return void 0;
34
+ return binding.by === latest.by && DateTime.Equivalence(binding.at, latest.at) ? binding : void 0;
35
+ };
36
+ const needsDigest = (input) => {
37
+ const bound = boundEntry(input);
38
+ return bound !== void 0 && Actor.allowed(bound.by, input.humans);
39
+ };
40
+ const evaluate = (input, content) => {
41
+ if (latestHumanOf(input.verified) === void 0) return { integrity: "none" };
42
+ const binding = input.binding;
43
+ if (binding === void 0) return {
44
+ integrity: "drifted",
45
+ drift: { reason: "unbound" }
46
+ };
47
+ if (boundEntry(input) === void 0) return {
48
+ integrity: "drifted",
49
+ drift: { reason: "entry-superseded" }
50
+ };
51
+ if (!Actor.allowed(binding.by, input.humans)) return {
52
+ integrity: "drifted",
53
+ drift: { reason: "actor-not-allowed" }
54
+ };
55
+ if (content === void 0) throw new Error("Trust.evaluate: a bound verification needs a content digest");
56
+ if (content === binding.content) return { integrity: "current" };
57
+ const generated = input.generated;
58
+ return {
59
+ integrity: "drifted",
60
+ drift: {
61
+ reason: "content-changed",
62
+ ...generated?.at !== void 0 && DateTime.isGreaterThan(generated.at, binding.at) ? {
63
+ editedBy: generated.by,
64
+ editedAt: encodeAt(generated.at)
65
+ } : {}
66
+ }
67
+ };
68
+ };
69
+ const signal = Effect.fn("Trust.signal")(function* (concept, config, now) {
70
+ const tier = Derive.trustTier(concept.frontmatter);
71
+ const stale = Derive.isStale(concept.frontmatter, now);
72
+ const input = inputOf(concept, config);
73
+ const content = needsDigest(input) ? yield* Derivation.contentDigest(concept.frontmatter.raw, concept.document.source) : void 0;
74
+ return {
75
+ tier,
76
+ stale,
77
+ ...evaluate(input, content)
78
+ };
79
+ });
80
+ const signals = Effect.fn("Trust.signals")(function* (bundle, config, now) {
81
+ const out = /* @__PURE__ */ new Map();
82
+ for (const [id, concept] of bundle.concepts) out.set(id, yield* signal(concept, config, now));
83
+ return out;
84
+ });
85
+ const messageFor = (concept, drift) => {
86
+ const latest = latestHuman(concept);
87
+ const by = latest?.by ?? "a human";
88
+ const at = latest === void 0 ? "an unknown time" : encodeAt(latest.at);
89
+ if (drift.reason === "unbound") return `verified by ${by} at ${at}, but no integrity.verified binding records what was verified; re-run okfit verify`;
90
+ if (drift.reason === "entry-superseded") return `the latest human verification (${by} at ${at}) is not the one integrity.verified binds; re-run okfit verify`;
91
+ const binding = concept.frontmatter.integrity?.verified;
92
+ if (drift.reason === "actor-not-allowed") return `verified by ${binding?.by ?? by}, who is not in [actors].humans; a listed human re-runs okfit verify`;
93
+ const edited = drift.editedBy === void 0 ? "" : ` (edited by ${drift.editedBy} at ${drift.editedAt})`;
94
+ return `content changed since ${binding?.by ?? by} verified it at ${binding === void 0 ? at : encodeAt(binding.at)}${edited}; re-run okfit verify`;
95
+ };
96
+ /**
97
+ * Where a `verified-drift` finding points (spec section 6): the binding when one exists,
98
+ * else the latest human `verified` entry -- by its list index when `verified` is a
99
+ * sequence, the key itself when it is a single bare mapping.
100
+ */
101
+ const rangePath = (concept) => {
102
+ if (concept.frontmatter.integrity?.verified !== void 0) return ["integrity", "verified"];
103
+ const latest = latestHuman(concept);
104
+ const index = latest === void 0 ? -1 : (concept.frontmatter.verified ?? []).indexOf(latest);
105
+ return Array.isArray(concept.frontmatter.raw.verified) && index >= 0 ? ["verified", index] : ["verified"];
106
+ };
107
+ const lint = Effect.fn("Trust.lint")(function* (bundle, config, now, given) {
108
+ const severity = OkfitConfig.severityFor(config, "verified-drift");
109
+ if (severity === "off") return [];
110
+ const diagnostics = [];
111
+ for (const [id, concept] of bundle.concepts) {
112
+ const status = Derive.status(concept.frontmatter);
113
+ if (status === "draft" || status === "deprecated") continue;
114
+ const result = given?.get(id) ?? (yield* signal(concept, config, now));
115
+ if (result.drift === void 0) continue;
116
+ const range = DiagnosticRange.forFrontmatterPath(concept.document, rangePath(concept));
117
+ diagnostics.push(Diagnostic.make({
118
+ file: concept.path,
119
+ code: "verified-drift",
120
+ severity,
121
+ message: messageFor(concept, result.drift),
122
+ ...range === void 0 ? {} : { range }
123
+ }));
124
+ }
125
+ return diagnostics;
126
+ });
127
+ /**
128
+ * The integrity trust signal: whether a human verification still binds the
129
+ * content it covered, and the `verified-drift` lint built on it. A signal, not
130
+ * a tamper seal. Named `Trust` so it does not collide with core's `Integrity`
131
+ * schema.
132
+ *
133
+ * @public
134
+ */
135
+ var Trust = class {
136
+ constructor() {}
137
+ /** The signal for one concept under `config`; hashes only when a human entry is bound by an allowed actor. */
138
+ static signal = signal;
139
+ /** The signal for every concept in a bundle, keyed by concept id. */
140
+ static signals = signals;
141
+ /** Pure integrity decision; no hashing. `content` may be `undefined` only when {@link Trust.needsDigest} is false. */
142
+ static evaluate = evaluate;
143
+ /** True iff the latest human entry is bound by `integrity.verified` and its actor passes the allow-list, i.e. a digest is needed. */
144
+ static needsDigest = needsDigest;
145
+ /** The {@link EvaluateInput} of a loaded concept. */
146
+ static inputOf = inputOf;
147
+ /**
148
+ * `verified-drift` diagnostics for drifted concepts (drafts and deprecated concepts are exempt: `okfit verify` refuses them); `[]` (no hashing) when the severity is `off`.
149
+ * Given `signals` (from {@link Trust.signals}) it reuses each concept's entry and hashes nothing for it;
150
+ * a concept absent from `signals` falls back to computing its own signal (hashing if it needs a digest).
151
+ */
152
+ static lint = lint;
153
+ };
154
+
155
+ //#endregion
156
+ export { DriftReason, Trust };
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context, Crypto, DateTime, Effect, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
2
2
  import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
3
- import { Actor, Diagnostic, DiagnosticRange, LoadedBundle, LoadedConcept, OkfitConfig } from "@okfit/core";
3
+ import { Actor, Diagnostic, DiagnosticRange, Digest, LoadedBundle, LoadedConcept, OkfitConfig, TrustTier } from "@okfit/core";
4
4
  import { ChildProcessSpawner } from "effect/process";
5
5
  //#region src/BodyProvenance.d.ts
6
6
  /**
@@ -60,6 +60,22 @@ export type BodyProvenance = typeof BodyProvenance.Type;
60
60
  */
61
61
  type UncommittedReason = typeof BodyUncommitted.fields.reason.Type;
62
62
  //#endregion
63
+ //#region src/Canonical.d.ts
64
+ /**
65
+ * Canonical encoding for okfit's content digest.
66
+ *
67
+ * @public
68
+ */
69
+ export declare class Canonical {
70
+ private constructor();
71
+ /** Top-level frontmatter keys excluded from the content digest. */
72
+ static readonly EXCLUDED_KEYS: ReadonlySet<string>;
73
+ /** Canonical JSON: sorted keys at every depth, array order kept; a `bigint` is its bare decimal literal. */
74
+ static readonly json: (value: unknown) => string;
75
+ /** The exact string a content digest hashes. */
76
+ static readonly contentInput: (raw: Readonly<Record<string, unknown>>, source: string) => string;
77
+ }
78
+ //#endregion
63
79
  //#region src/GitHistory.d.ts
64
80
  declare const PathHistoryEntry_base: Schema.Class<PathHistoryEntry, Schema.Struct<{
65
81
  readonly sha: Schema.String;
@@ -178,6 +194,31 @@ declare const AgentActorUnconfiguredError_base: Schema.Class<AgentActorUnconfigu
178
194
  export declare class AgentActorUnconfiguredError extends AgentActorUnconfiguredError_base {
179
195
  get message(): string;
180
196
  }
197
+ declare const ActorConfigInvalidError_base: Schema.Class<ActorConfigInvalidError, Schema.TaggedStruct<"ActorConfigInvalidError", {
198
+ readonly value: Schema.String;
199
+ }>, import("effect/Cause").YieldableError>;
200
+ /**
201
+ * `git config okfit.actor` is set but is not a `human:<id>` actor without `@`
202
+ * or glob characters. The value stays on the person's machine; okfit never
203
+ * writes it anywhere.
204
+ *
205
+ * @public
206
+ */
207
+ export declare class ActorConfigInvalidError extends ActorConfigInvalidError_base {
208
+ get message(): string;
209
+ }
210
+ declare const HumanActorNotAllowedError_base: Schema.Class<HumanActorNotAllowedError, Schema.TaggedStruct<"HumanActorNotAllowedError", {
211
+ readonly actor: Schema.String;
212
+ }>, import("effect/Cause").YieldableError>;
213
+ /**
214
+ * The resolved human is not on a non-empty `[actors].humans` allow-list, so it
215
+ * may not verify or revoke (spec section 8).
216
+ *
217
+ * @public
218
+ */
219
+ export declare class HumanActorNotAllowedError extends HumanActorNotAllowedError_base {
220
+ get message(): string;
221
+ }
181
222
  /**
182
223
  * Options for {@link Derivation.generatedAt} (P-39).
183
224
  *
@@ -214,7 +255,13 @@ interface GeneratedByOptions {
214
255
  *
215
256
  * @public
216
257
  */
217
- type GeneratedByError = AgentActorUnconfiguredError | HumanActorUnresolvedError | GitCommandError | NotARepositoryError | UnknownRefError;
258
+ type GeneratedByError = ActorConfigInvalidError | AgentActorUnconfiguredError | HumanActorUnresolvedError | GitCommandError | NotARepositoryError | UnknownRefError;
259
+ /**
260
+ * Everything {@link Derivation.verifier} can fail with.
261
+ *
262
+ * @public
263
+ */
264
+ type VerifierError = GeneratedByError | HumanActorNotAllowedError;
218
265
  /**
219
266
  * Trimmed `user.name` / `user.email` as `Git.configGet` answers them; a key is absent when git has no value.
220
267
  *
@@ -240,11 +287,32 @@ export declare class Derivation {
240
287
  */
241
288
  static readonly body: (text: string) => string;
242
289
  /**
243
- * Lowercase 64-char hex sha256 of `Derivation.body(text)`, through `effect`'s platform-agnostic
244
- * `Crypto` service (issue #19). Same normalization as `body`, so a frontmatter-only edit or a
245
- * `core.autocrlf` worktree never changes the digest.
290
+ * Full lowercase 64-char hex sha256 of a string. Tests only.
291
+ *
292
+ * @internal
293
+ */
294
+ static readonly digestFull: (text: string) => Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>;
295
+ /**
296
+ * The okfit digest of a string: the first 16 lowercase hex characters of its SHA-256, through
297
+ * `effect`'s platform-agnostic `Crypto` service.
298
+ */
299
+ static readonly digest: (text: string) => Effect.Effect<Digest, PlatformError.PlatformError, Crypto.Crypto>;
300
+ /**
301
+ * 16-hex digest of `Derivation.body(source)` (issue #19). Same normalization as `body`, so a
302
+ * frontmatter-only edit or a `core.autocrlf` worktree never changes the digest.
303
+ */
304
+ static readonly bodyDigest: (source: string) => Effect.Effect<Digest, PlatformError.PlatformError, Crypto.Crypto>;
305
+ /**
306
+ * The body digest recorded for a generated concept: `integrity.generated.body`, else the legacy
307
+ * `generated.body_sha256` cut to its first 16 hex characters. `undefined` when neither is present.
308
+ * Pure; every "is the stamped body still current" comparison reads through this.
309
+ */
310
+ static readonly recordedBody: (concept: LoadedConcept) => string | undefined;
311
+ /**
312
+ * 16-hex digest of the canonical content input (`Canonical.contentInput`): authored frontmatter
313
+ * plus the normalised body, insensitive to style, key order and CRLF.
246
314
  */
247
- static readonly bodyDigest: (text: string) => Effect.Effect<string, PlatformError.PlatformError, Crypto.Crypto>;
315
+ static readonly contentDigest: (raw: Readonly<Record<string, unknown>>, source: string) => Effect.Effect<Digest, PlatformError.PlatformError, Crypto.Crypto>;
248
316
  /**
249
317
  * Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
250
318
  * 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
@@ -265,18 +333,30 @@ export declare class Derivation {
265
333
  static readonly generatedAt: (options: GeneratedAtOptions) => Effect.Effect<BodyProvenance, GeneratedAtError, Git | GitHistory | FileSystem.FileSystem | Path.Path>;
266
334
  /**
267
335
  * P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
268
- * and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
269
- * candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
336
+ * and the `user.name` slug. Order, first hit wins: (1) a literal `human:` entry of `humans` (glob entries,
337
+ * those with `*` or `?`, are skipped) whose id equals a candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
270
338
  * The result is branded through core's `Actor` codec and always passes it.
271
339
  */
272
- static readonly humanActorId: (identity: GitIdentity, humans: ReadonlyArray<Actor>) => Option.Option<Actor>;
340
+ static readonly humanActorId: (identity: GitIdentity, humans: ReadonlyArray<string>) => Option.Option<Actor>;
273
341
  /**
274
342
  * Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
275
- * (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
343
+ * (P-17). `"human"`: first `Git.configGet(cwd, "okfit.actor")`, which wins when set and must be a
344
+ * `human:<id>` with no `@` or glob ({@link ActorConfigInvalidError} otherwise), returned in the spelling of an exact `[actors].humans` entry that equals it case-insensitively (P-14); else `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
276
345
  * scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
277
346
  * Answers only "who is writing now" (P-40).
278
347
  */
279
348
  static readonly generatedBy: (options: GeneratedByOptions) => Effect.Effect<Actor, GeneratedByError, Git>;
349
+ /**
350
+ * The human allowed to verify now: {@link Derivation.generatedBy} for a human
351
+ * writer, then the `[actors].humans` allow-list (`Actor.allowed`): empty or
352
+ * absent allows anyone, otherwise the actor must match an entry or this fails
353
+ * {@link HumanActorNotAllowedError}. `okfit verify`, `--revoke` and the LSP
354
+ * verify actions all resolve through this.
355
+ */
356
+ static readonly verifier: (options: {
357
+ readonly cwd: string;
358
+ readonly config: OkfitConfig;
359
+ }) => Effect.Effect<Actor, VerifierError, Git>;
280
360
  /**
281
361
  * P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
282
362
  * 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
@@ -431,17 +511,20 @@ export declare class Profiles {
431
511
  * like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
432
512
  * are unchanged.
433
513
  *
434
- * Two tiers per concept (issue #19, body-digest design): when
435
- * `generated.body_sha256` is present, drift is decided ENTIRELY by comparing it to
514
+ * Two tiers per concept (issue #19, body-digest design): when a body digest
515
+ * is recorded (`integrity.generated.body`, else the 16-hex prefix of a legacy
516
+ * `generated.body_sha256`, via `Derivation.recordedBody`), drift is decided
517
+ * ENTIRELY by comparing it to
436
518
  * `Derivation.bodyDigest` of the concept's current source -- no git call at
437
519
  * all, so this works for a dirty worktree too (that is the point: an edited
438
520
  * body is caught immediately, before it is even committed) and survives a
439
521
  * squash/rebase merge that only rewrites commit dates, since the body
440
- * itself, and therefore its digest, never changed. When `body_sha256` is
441
- * absent (an un-migrated concept), this falls back to the original
522
+ * itself, and therefore its digest, never changed. When no digest is recorded
523
+ * at all (a concept `okfit sync` has never stamped), this falls back to the original
442
524
  * git-derived date comparison against `Derivation.generatedAt`, preserving
443
525
  * `--skip-provenance`'s meaning for bundles that have not run `okfit sync`
444
- * since this field was introduced.
526
+ * since the digest was introduced. The drift message names the field the
527
+ * recorded value came from, so a legacy concept reads `generated.body_sha256`.
445
528
  *
446
529
  * The tier-2 fallback returns `[]` the moment a `NotARepositoryError`
447
530
  * surfaces: a bundle outside git has nothing to derive for any concept still
@@ -459,8 +542,8 @@ export declare class Profiles {
459
542
  * calling at all; the two agree by construction (both skip the walk).
460
543
  *
461
544
  * A third, optional options argument carries `skipGitTier`: when `true`, a
462
- * concept that falls through to tier 2 (no `generated.body_sha256`
463
- * recorded) is skipped entirely -- no `Derivation.generatedAt` call, no
545
+ * concept that falls through to tier 2 (no body digest recorded) is
546
+ * skipped entirely -- no `Derivation.generatedAt` call, no
464
547
  * git spawn at all -- while tier 1 still runs, unaffected, for every
465
548
  * concept that DOES carry a digest. This is a runtime skip, not a
466
549
  * type-level narrowing: the R channel still lists `Git | GitHistory` even
@@ -499,13 +582,13 @@ export declare const PUBLICATION_TYPE: "Publication";
499
582
  interface RendersEntry {
500
583
  readonly index: number;
501
584
  readonly path: string;
502
- readonly body_sha256?: string;
585
+ readonly body?: string;
503
586
  }
504
587
  /**
505
588
  * A profiles-owned lint over Publication concepts: `publication-orphan` when a
506
589
  * `surface` or `renders[].path` does not resolve to a bundle concept (or
507
590
  * `renders` is malformed), and `publication-drift` when a rendered source's
508
- * body digest no longer matches the stamped `body_sha256`. Total over
591
+ * body digest no longer matches the stamped `renders[].body`. Total over
509
592
  * severity: returns `[]` before any digest work when both codes are `"off"`.
510
593
  *
511
594
  * @public
@@ -520,5 +603,98 @@ export declare class Publications {
520
603
  static readonly lint: (bundle: LoadedBundle, config: OkfitConfig) => Effect.Effect<ReadonlyArray<Diagnostic>, PlatformError.PlatformError, Crypto.Crypto>;
521
604
  }
522
605
  //#endregion
523
- export type { DocsPreset, GeneratedAtError, GeneratedAtOptions, GeneratedByError, GeneratedByOptions, GitHistoryShape, GitIdentity, Layout, LayoutDirectory, PathLogOptions, Profile, ProfileName, RendersEntry, SurfaceTemplate, UncommittedReason, Writer };
606
+ //#region src/Trust.d.ts
607
+ /**
608
+ * Whether a concept's human verification still covers its content: `none` (no
609
+ * `human:` entry in `verified`), `current` (the latest human entry is bound by
610
+ * `integrity.verified` and the content digest still matches), or `drifted`.
611
+ *
612
+ * @public
613
+ */
614
+ type IntegrityState = "current" | "drifted" | "none";
615
+ /**
616
+ * Why a human verification no longer binds: the content changed, the entry has
617
+ * no `integrity.verified` binding, a later human entry is not the bound one, or
618
+ * the bound actor fails a non-empty `[actors].humans` allow-list.
619
+ *
620
+ * @public
621
+ */
622
+ export declare const DriftReason: Schema.Literals<readonly ["content-changed", "unbound", "entry-superseded", "actor-not-allowed"]>;
623
+ /**
624
+ * The type of {@link (DriftReason:variable)}.
625
+ * @public
626
+ */
627
+ export type DriftReason = typeof DriftReason.Type;
628
+ /**
629
+ * The trust signal for one concept: its tier, whether its human verification
630
+ * still binds, and staleness. `drift` is present only when `integrity` is
631
+ * `drifted`.
632
+ *
633
+ * @public
634
+ */
635
+ interface ConceptSignal {
636
+ readonly tier: TrustTier;
637
+ readonly integrity: IntegrityState;
638
+ readonly drift?: {
639
+ readonly reason: DriftReason;
640
+ readonly editedBy?: Actor;
641
+ readonly editedAt?: string;
642
+ };
643
+ readonly stale: boolean;
644
+ }
645
+ /**
646
+ * What the integrity decision reads from a concept: its `verified` entries, the
647
+ * `integrity.verified` binding, and `generated`. Built by {@link Trust.inputOf}.
648
+ *
649
+ * @public
650
+ */
651
+ interface EvaluateInput {
652
+ readonly verified: ReadonlyArray<{
653
+ readonly by: Actor;
654
+ readonly at: DateTime.Utc;
655
+ }>;
656
+ readonly binding: {
657
+ readonly by: Actor;
658
+ readonly at: DateTime.Utc;
659
+ readonly content: string;
660
+ } | undefined;
661
+ readonly generated: {
662
+ readonly by: Actor;
663
+ readonly at?: DateTime.Utc;
664
+ } | undefined;
665
+ /** The `[actors].humans` allow-list; empty allows anyone. */
666
+ readonly humans: ReadonlyArray<string>;
667
+ }
668
+ /**
669
+ * The integrity trust signal: whether a human verification still binds the
670
+ * content it covered, and the `verified-drift` lint built on it. A signal, not
671
+ * a tamper seal. Named `Trust` so it does not collide with core's `Integrity`
672
+ * schema.
673
+ *
674
+ * @public
675
+ */
676
+ export declare class Trust {
677
+ private constructor();
678
+ /** The signal for one concept under `config`; hashes only when a human entry is bound by an allowed actor. */
679
+ static readonly signal: (concept: LoadedConcept, config: OkfitConfig, now: DateTime.Utc) => Effect.Effect<ConceptSignal, PlatformError.PlatformError, Crypto.Crypto>;
680
+ /** The signal for every concept in a bundle, keyed by concept id. */
681
+ static readonly signals: (bundle: LoadedBundle, config: OkfitConfig, now: DateTime.Utc) => Effect.Effect<Map<string, ConceptSignal>, PlatformError.PlatformError, Crypto.Crypto>;
682
+ /** Pure integrity decision; no hashing. `content` may be `undefined` only when {@link Trust.needsDigest} is false. */
683
+ static readonly evaluate: (input: EvaluateInput, content: string | undefined) => {
684
+ readonly integrity: IntegrityState;
685
+ readonly drift?: NonNullable<ConceptSignal["drift"]>;
686
+ };
687
+ /** True iff the latest human entry is bound by `integrity.verified` and its actor passes the allow-list, i.e. a digest is needed. */
688
+ static readonly needsDigest: (input: EvaluateInput) => boolean;
689
+ /** The {@link EvaluateInput} of a loaded concept. */
690
+ static readonly inputOf: (concept: LoadedConcept, config: OkfitConfig) => EvaluateInput;
691
+ /**
692
+ * `verified-drift` diagnostics for drifted concepts (drafts and deprecated concepts are exempt: `okfit verify` refuses them); `[]` (no hashing) when the severity is `off`.
693
+ * Given `signals` (from {@link Trust.signals}) it reuses each concept's entry and hashes nothing for it;
694
+ * a concept absent from `signals` falls back to computing its own signal (hashing if it needs a digest).
695
+ */
696
+ static readonly lint: (bundle: LoadedBundle, config: OkfitConfig, now: DateTime.Utc, signals?: ReadonlyMap<string, ConceptSignal>) => Effect.Effect<Array<Diagnostic>, PlatformError.PlatformError, Crypto.Crypto>;
697
+ }
698
+ //#endregion
699
+ export type { ConceptSignal, DocsPreset, EvaluateInput, GeneratedAtError, GeneratedAtOptions, GeneratedByError, GeneratedByOptions, GitHistoryShape, GitIdentity, IntegrityState, Layout, LayoutDirectory, PathLogOptions, Profile, ProfileName, RendersEntry, SurfaceTemplate, UncommittedReason, VerifierError, Writer };
524
700
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { BodyCommitted, BodyProvenance, BodyUncommitted } from "./BodyProvenance.js";
2
+ import { Canonical } from "./Canonical.js";
2
3
  import { GitHistory, GitHistoryError, PathHistoryEntry } from "./GitHistory.js";
3
- import { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError } from "./Derivation.js";
4
+ import { ActorConfigInvalidError, AgentActorUnconfiguredError, Derivation, HumanActorNotAllowedError, HumanActorUnresolvedError } from "./Derivation.js";
4
5
  import { PROFILE_NAMES, ProfileDiagnostic, ProfileDiagnosticCode } from "./Profile.js";
5
6
  import { Profiles } from "./Profiles.js";
6
7
  import { Provenance } from "./Provenance.js";
7
8
  import { PUBLICATION_TYPE, Publications, SURFACE_TYPE } from "./Publications.js";
9
+ import { DriftReason, Trust } from "./Trust.js";
8
10
 
9
- export { AgentActorUnconfiguredError, BodyCommitted, BodyProvenance, BodyUncommitted, Derivation, GitHistory, GitHistoryError, HumanActorUnresolvedError, PROFILE_NAMES, PUBLICATION_TYPE, PathHistoryEntry, ProfileDiagnostic, ProfileDiagnosticCode, Profiles, Provenance, Publications, SURFACE_TYPE };
11
+ export { ActorConfigInvalidError, AgentActorUnconfiguredError, BodyCommitted, BodyProvenance, BodyUncommitted, Canonical, Derivation, DriftReason, GitHistory, GitHistoryError, HumanActorNotAllowedError, HumanActorUnresolvedError, PROFILE_NAMES, PUBLICATION_TYPE, PathHistoryEntry, ProfileDiagnostic, ProfileDiagnosticCode, Profiles, Provenance, Publications, SURFACE_TYPE, Trust };
@@ -0,0 +1,13 @@
1
+ import { FrontmatterSource } from "@effected/markdown";
2
+
3
+ //#region src/internal/body.ts
4
+ const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
5
+ /**
6
+ * The normalised body of a concept source (`FrontmatterSource.split(text).body`, then P-6 normalisation).
7
+ *
8
+ * @internal
9
+ */
10
+ const body = (text) => normalise(FrontmatterSource.split(text).body);
11
+
12
+ //#endregion
13
+ export { body };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okfit/profiles",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Named OKF configuration profiles for okfit, starting with the software-project profile.",
6
6
  "keywords": [
@@ -35,9 +35,9 @@
35
35
  "./package.json": "./package.json"
36
36
  },
37
37
  "peerDependencies": {
38
- "@effected/git": "^0.20.0",
39
- "@effected/markdown": "^0.15.0",
40
- "@okfit/core": "^0.9.0",
38
+ "@effected/git": "^0.21.0",
39
+ "@effected/markdown": "^0.16.0",
40
+ "@okfit/core": "^0.10.0",
41
41
  "effect": "^4.0.0"
42
42
  },
43
43
  "engines": {