feat(gateway): human-fragment schema + generated registry projection [M1 A3 pt1]

M1 gateway A3 (part 1: the fragment/registry CONTRACT + proof-5). Schema sealed b2a2594b
(supersedes e499dab6): prefs PER-ENTITY (loudness once per human), role PER-BINDING (routing;
exactly-one-primary = default channel) — two distinct axes; deliveryClass+away only.

- Fragment = one YAML per human at <home>/gateway/humans/<entityId>.yaml. Closed enums
  (class 'human', kind 'slack', role primary|secondary, deliveryClass A-D); entityId is a
  slug = filename key; secretsRef is a vault POINTER (never the secret). validateHumanFragment
  enforces >=1 binding + EXACTLY-ONE-primary-per-entity; add-time == load-time.
- Registry = GENERATED projection (humans.generated.yaml, DO-NOT-EDIT header) via the ONE
  projectHumans generator; addHumanFragment is the verb-add-only atomic writer (tmp+rename,
  re-projects). loadHumanRegistry REFUSES a hand-edited/drifted projection (fragment is truth)
  — the runtime drift pin. operator-human@kernel = the fallback slot, NOT a fragment.
- Mirrors two precedents: hosts-registry addHostEntry (atomic verb-add + add==load validation)
  + the codegen-mirror DO-NOT-EDIT/drift convention.

PROOF-5 GREEN (16/16, hermetic tmp-home): two fragments -> sorted projection; a fragment edit
re-projects; a projection hand-edit is REFUSED at load; invalid fragment + filename!=entityId
fail LOUD. tsc 0. Based on local main (A2 fold-33 3e688f63). Never push.

PART 2 (next, noted for review): the `rig gateway human add` CLI verb (nested-binding ergonomics)
+ the daemon-side reader A4 resolves against. FLAG-1/FLAG-2 ruled per b2a2594b (per-entity prefs,
per-binding role). operator-human@kernel default-slot resolution wiring rides A6/A4.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-06 18:00:18 +00:00
1 parent 3e688f6347
commit 87bcbfa6a6
2 files changed
+394

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
// OPR gateway M1 A3 — human specs: file-per-human FRAGMENTS + a GENERATED registry
// projection. Schema sealed in GATEWAY-M1-A3-ENTITY-SCHEMA (b2a2594b, supersedes
// e499dab6): prefs are PER-ENTITY (loudness once per human) and `role` is PER-BINDING
// (routing / default delivery channel) — two DISTINCT axes.
//
// TRUTH MODEL (design-record §7 / plan §1): each human is one YAML fragment at
// <home>/gateway/humans/<entityId>.yaml. The registry is a GENERATED PROJECTION of
// the fragments (humans.generated.yaml) — NEVER hand-edited; the fragment is truth.
// The projection carries a DO-NOT-EDIT header and a drift pin (mirrors the
// attestation-lineage.generated codegen convention). Admission/resolution is the
// A1/A4 gateway's job — this module only owns the fragment + projection contract.
//
// Validation is add-time == load-time (the hosts-registry entry pattern): the SAME
// validateHumanFragment runs on `add` and on load, so a present-but-invalid fragment
// is a loud error, never silently projected.
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { getOpenRigHome } from "../openrig-compat.js";
// ── Schema (closed enums; extend only additively behind the contract) ──
export const HUMAN_ENTITY_CLASSES = new Set(["human"]); // M1's only class
export const HUMAN_CONNECTOR_KINDS = new Set(["slack"]); // M1's only kind
export const HUMAN_BINDING_ROLES = new Set(["primary", "secondary"]);
export const HUMAN_DELIVERY_CLASSES = new Set(["A", "B", "C", "D"]);
// entityId is the fragment key + filename: a stable slug that survives platform
// renames. Lowercase alnum + separators, no path chars (it is spliced into a path).
export const ENTITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
export interface HumanConnectorBinding {
kind: "slack";
connectorRef: string;
/** POINTER at the connector's vault (secrets-on-connector) — NEVER the secret. */
secretsRef: string;
/** Per-BINDING routing: EXACTLY ONE primary per entity = the default channel. */
role: "primary" | "secondary";
}
export interface HumanPrefs {
/** Per-ENTITY loudness — a SELECTION from the notifications register (spec §6);
* A3 carries it, never redefines it. Forward-compatible if the register grows. */
deliveryClass: "A" | "B" | "C" | "D";
/** The AWAY preset (optional). */
away?: boolean;
}
export interface HumanFragment {
entityId: string;
class: "human";
displayName: string;
/** The registered @external ref, = <entityId>@external by convention. */
address: string;
connectorBindings: HumanConnectorBinding[];
prefs: HumanPrefs;
}
export type ValidateResult =
| { ok: true; fragment: HumanFragment }
| { ok: false; error: string };
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** add-time == load-time validation. Returns the typed fragment or a loud error.
* Structural + closed-enum + the two cross-field invariants (>=1 binding,
* exactly-one-primary-per-entity). Does NOT touch the filesystem. */
export function validateHumanFragment(raw: unknown): ValidateResult {
if (!isObj(raw)) return { ok: false, error: "human fragment must be a mapping" };
const { entityId, class: cls, displayName, address, connectorBindings, prefs } = raw;
if (typeof entityId !== "string" || !ENTITY_ID_PATTERN.test(entityId)) {
return { ok: false, error: `entityId "${String(entityId)}" must be a lowercase slug (a-z0-9._- , no leading/trailing separator)` };
}
if (cls !== "human") {
return { ok: false, error: `class "${String(cls)}" is not a known entity class (M1: ${[...HUMAN_ENTITY_CLASSES].join(", ")})` };
}
if (typeof displayName !== "string" || displayName.length === 0) {
return { ok: false, error: "displayName must be a non-empty string" };
}
if (typeof address !== "string" || address.length === 0) {
return { ok: false, error: "address must be a non-empty string (the registered @external ref)" };
}
if (!Array.isArray(connectorBindings) || connectorBindings.length < 1) {
return { ok: false, error: "connectorBindings must be a non-empty list (>=1)" };
}
const bindings: HumanConnectorBinding[] = [];
let primaryCount = 0;
for (let i = 0; i < connectorBindings.length; i++) {
const b = connectorBindings[i];
if (!isObj(b)) return { ok: false, error: `connectorBindings[${i}] must be a mapping` };
if (!HUMAN_CONNECTOR_KINDS.has(String(b.kind))) {
return { ok: false, error: `connectorBindings[${i}].kind "${String(b.kind)}" is not a known connector kind (M1: ${[...HUMAN_CONNECTOR_KINDS].join(", ")})` };
}
if (typeof b.connectorRef !== "string" || b.connectorRef.length === 0) {
return { ok: false, error: `connectorBindings[${i}].connectorRef must be a non-empty string` };
}
if (typeof b.secretsRef !== "string" || b.secretsRef.length === 0) {
return { ok: false, error: `connectorBindings[${i}].secretsRef must be a non-empty vault POINTER (never the secret)` };
}
if (!HUMAN_BINDING_ROLES.has(String(b.role))) {
return { ok: false, error: `connectorBindings[${i}].role "${String(b.role)}" must be primary|secondary` };
}
if (b.role === "primary") primaryCount++;
bindings.push({ kind: "slack", connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" });
}
// EXACTLY ONE primary binding per entity = the default delivery channel.
if (primaryCount !== 1) {
return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` };
}
if (!isObj(prefs)) return { ok: false, error: "prefs must be a mapping { deliveryClass, away? }" };
if (!HUMAN_DELIVERY_CLASSES.has(String(prefs.deliveryClass))) {
return { ok: false, error: `prefs.deliveryClass "${String(prefs.deliveryClass)}" must be one of A|B|C|D (the notifications register)` };
}
if (prefs.away !== undefined && typeof prefs.away !== "boolean") {
return { ok: false, error: "prefs.away must be a boolean when present" };
}
const validatedPrefs: HumanPrefs = { deliveryClass: prefs.deliveryClass as HumanPrefs["deliveryClass"] };
if (prefs.away !== undefined) validatedPrefs.away = prefs.away;
return {
ok: true,
fragment: { entityId, class: "human", displayName, address, connectorBindings: bindings, prefs: validatedPrefs },
};
}
// ── Paths (under getOpenRigHome(); `home` injectable for hermetic tests) ──
export function humansDir(home: string = getOpenRigHome()): string {
return join(home, "gateway", "humans");
}
export function projectionPath(home: string = getOpenRigHome()): string {
return join(home, "gateway", "humans.generated.yaml");
}
/** The default inbound slot when no addressable entity resolves (slice-11 default,
* unchanged). NOT a fragment — the fallback target, never an entities[] entry. */
export const OPERATOR_HUMAN_DEFAULT_SLOT = "operator-human@kernel";
const PROJECTION_HEADER =
"# GENERATED FILE — DO NOT EDIT.\n" +
"# Projection of the human fragments under gateway/humans/<entityId>.yaml.\n" +
"# The fragment is truth: add/edit a human via its fragment (or `rig gateway human\n" +
"# add`), then re-project. A hand-edit here is REFUSED at load.\n";
export type ProjectResult =
| { ok: true; body: string; entities: HumanFragment[] }
| { ok: false; error: string };
/** Generate the canonical registry projection from the fragments (the ONE generator
* used by both the write path and the drift/load check). Load-time validation is the
* SAME validateHumanFragment as add-time; the filename must equal <entityId>.yaml
* (collision-free key). Entities are sorted by entityId for a stable, diffable body. */
export function projectHumans(home: string = getOpenRigHome()): ProjectResult {
const dir = humansDir(home);
const entities: HumanFragment[] = [];
if (existsSync(dir)) {
const files = readdirSync(dir).filter((f) => f.endsWith(".yaml") && !f.startsWith(".")).sort();
for (const f of files) {
let raw: unknown;
try {
raw = parseYaml(readFileSync(join(dir, f), "utf8"));
} catch (err) {
return { ok: false, error: `failed to parse human fragment ${f}: ${(err as Error).message}` };
}
const v = validateHumanFragment(raw);
if (!v.ok) return { ok: false, error: `invalid human fragment ${f}: ${v.error}` };
if (`${v.fragment.entityId}.yaml` !== f) {
return { ok: false, error: `human fragment ${f} declares entityId "${v.fragment.entityId}" — the filename must be <entityId>.yaml` };
}
entities.push(v.fragment);
}
}
entities.sort((a, b) => (a.entityId < b.entityId ? -1 : a.entityId > b.entityId ? 1 : 0));
return { ok: true, body: PROJECTION_HEADER + stringifyYaml({ entities }), entities };
}
function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
try {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, body, { mode: 0o600 });
renameSync(tmp, path);
return { ok: true };
} catch (err) {
return { ok: false, error: `failed to write ${path}: ${(err as Error).message}` };
}
}
/** (Re)write the generated projection from the current fragments. Atomic. */
export function writeProjection(home: string = getOpenRigHome()): { ok: true; path: string } | { ok: false; error: string } {
const proj = projectHumans(home);
if (!proj.ok) return { ok: false, error: proj.error };
const path = projectionPath(home);
const w = atomicWrite(path, proj.body);
return w.ok ? { ok: true, path } : w;
}
export type AddHumanResult =
| { ok: true; path: string; fragment: HumanFragment }
| { ok: false; error: string };
/** The verb-add writer: validate (add-time) -> atomic write the ONE fragment file ->
* re-project. Operators never hand-create the fragment YAML; the verb owns it. */
export function addHumanFragment(raw: unknown, home: string = getOpenRigHome()): AddHumanResult {
const v = validateHumanFragment(raw);
if (!v.ok) return { ok: false, error: v.error };
const file = join(humansDir(home), `${v.fragment.entityId}.yaml`);
const w = atomicWrite(file, stringifyYaml(v.fragment));
if (!w.ok) return { ok: false, error: w.error };
const proj = writeProjection(home);
if (!proj.ok) return { ok: false, error: `fragment written but re-projection failed: ${proj.error}` };
return { ok: true, path: file, fragment: v.fragment };
}
export type LoadResult =
| { ok: true; entities: HumanFragment[] }
| { ok: false; error: string };
/** Load the registry via the projection, but REFUSE a hand-edited/drifted projection:
* the fragments are truth, so the on-disk projection must byte-match a fresh one.
* This is the drift pin's runtime half (the vitest parity pin is the CI half). */
export function loadHumanRegistry(home: string = getOpenRigHome()): LoadResult {
const proj = projectHumans(home);
if (!proj.ok) return { ok: false, error: proj.error };
const path = projectionPath(home);
if (!existsSync(path)) {
return { ok: false, error: `registry projection missing at ${path} — re-project from the fragments` };
}
if (readFileSync(path, "utf8") !== proj.body) {
return { ok: false, error: `registry projection at ${path} is HAND-EDITED or DRIFTED from the fragments — the fragment is truth; re-project, never hand-edit` };
}
return { ok: true, entities: proj.entities };
}
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { stringify as stringifyYaml } from "yaml";
import {
validateHumanFragment,
addHumanFragment,
projectHumans,
writeProjection,
loadHumanRegistry,
humansDir,
projectionPath,
OPERATOR_HUMAN_DEFAULT_SLOT,
type HumanFragment,
} from "../src/gateway/human-registry.js";
// M1 A3 — human fragments + generated registry projection. Schema b2a2594b
// (prefs per-ENTITY, role per-BINDING). Proof-5: two fragments -> projection;
// a fragment edit re-projects; the projection hand-edit refuses.
function fragment(over: Partial<HumanFragment> = {}): Record<string, unknown> {
return {
entityId: "mike",
class: "human",
displayName: "Mike",
address: "mike@external",
connectorBindings: [
{ kind: "slack", connectorRef: "slack-main", secretsRef: "vault://slack/mike", role: "primary" },
],
prefs: { deliveryClass: "B" },
...over,
};
}
describe("A3 human-fragment validation (add-time == load-time)", () => {
it("accepts a well-formed fragment", () => {
const r = validateHumanFragment(fragment());
expect(r.ok).toBe(true);
});
it("rejects an unknown class (closed enum)", () => {
const r = validateHumanFragment(fragment({ class: "agent" as HumanFragment["class"] }));
expect(r.ok).toBe(false);
});
it("rejects an unknown connector kind (closed enum)", () => {
const r = validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "telegram", connectorRef: "x", secretsRef: "v", role: "primary" }] });
expect(r.ok).toBe(false);
});
it("rejects zero connectorBindings (>=1)", () => {
const r = validateHumanFragment({ ...fragment(), connectorBindings: [] });
expect(r.ok).toBe(false);
});
it("rejects an entity with NO primary binding (exactly-one-primary)", () => {
const r = validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "slack", connectorRef: "x", secretsRef: "v", role: "secondary" }] });
expect(r.ok).toBe(false);
});
it("rejects an entity with TWO primary bindings (exactly-one-primary)", () => {
const r = validateHumanFragment({ ...fragment(), connectorBindings: [
{ kind: "slack", connectorRef: "a", secretsRef: "v1", role: "primary" },
{ kind: "slack", connectorRef: "b", secretsRef: "v2", role: "primary" },
] });
expect(r.ok).toBe(false);
});
it("accepts multiple bindings with exactly one primary (per-binding routing)", () => {
const r = validateHumanFragment({ ...fragment(), connectorBindings: [
{ kind: "slack", connectorRef: "a", secretsRef: "v1", role: "primary" },
{ kind: "slack", connectorRef: "b", secretsRef: "v2", role: "secondary" },
] });
expect(r.ok).toBe(true);
});
it("rejects a bad deliveryClass (A-D closed set)", () => {
const r = validateHumanFragment(fragment({ prefs: { deliveryClass: "E" } as HumanFragment["prefs"] }));
expect(r.ok).toBe(false);
});
it("rejects an empty secretsRef (pointer required, never the secret)", () => {
const r = validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "slack", connectorRef: "x", secretsRef: "", role: "primary" }] });
expect(r.ok).toBe(false);
});
it("rejects a non-slug entityId", () => {
expect(validateHumanFragment(fragment({ entityId: "Mike Jones" })).ok).toBe(false);
expect(validateHumanFragment(fragment({ entityId: "../evil" })).ok).toBe(false);
});
it("operator-human@kernel is the fallback slot, NOT a fragment", () => {
expect(OPERATOR_HUMAN_DEFAULT_SLOT).toBe("operator-human@kernel");
});
});
describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edit refuses", () => {
let home: string;
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "a3-humans-")); });
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
it("two fragments (founder + one) -> projection lists BOTH, sorted, DO-NOT-EDIT header", () => {
expect(addHumanFragment(fragment({ entityId: "mike", address: "mike@external" }), home).ok).toBe(true);
expect(addHumanFragment(fragment({ entityId: "founder", displayName: "Founder", address: "founder@external" }), home).ok).toBe(true);
// both fragment files exist
expect(existsSync(join(humansDir(home), "mike.yaml"))).toBe(true);
expect(existsSync(join(humansDir(home), "founder.yaml"))).toBe(true);
const proj = readFileSync(projectionPath(home), "utf8");
expect(proj).toContain("GENERATED FILE — DO NOT EDIT");
const loaded = loadHumanRegistry(home);
expect(loaded.ok).toBe(true);
if (loaded.ok) {
expect(loaded.entities.map((e) => e.entityId)).toEqual(["founder", "mike"]); // sorted
}
});
it("a fragment EDIT re-projects (projection tracks the fragment = truth)", () => {
addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "B" } }), home);
const before = readFileSync(projectionPath(home), "utf8");
// edit the fragment via the verb (deliveryClass B -> D) + re-project
addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "D" } }), home);
const after = readFileSync(projectionPath(home), "utf8");
expect(after).not.toBe(before);
expect(after).toContain("deliveryClass: D");
expect(loadHumanRegistry(home).ok).toBe(true);
});
it("a HAND-EDIT of the projection is REFUSED at load (fragment is truth)", () => {
addHumanFragment(fragment({ entityId: "mike" }), home);
expect(loadHumanRegistry(home).ok).toBe(true); // clean
// hand-edit the generated projection
const p = projectionPath(home);
writeFileSync(p, readFileSync(p, "utf8") + "\n# sneaky hand edit\n");
const loaded = loadHumanRegistry(home);
expect(loaded.ok).toBe(false);
if (!loaded.ok) expect(loaded.error).toMatch(/HAND-EDITED|DRIFT/i);
});
it("an INVALID fragment on disk fails projection LOUD (load-time == add-time)", () => {
addHumanFragment(fragment({ entityId: "mike" }), home);
// plant a structurally-invalid fragment beside the valid one
writeFileSync(join(humansDir(home), "bad.yaml"), "entityId: bad\nclass: human\n"); // missing bindings/prefs
const proj = projectHumans(home);
expect(proj.ok).toBe(false);
});
it("a filename != <entityId>.yaml is refused (collision-free key)", () => {
addHumanFragment(fragment({ entityId: "mike" }), home); // creates humansDir + mike.yaml
// a valid-content fragment under the WRONG filename (entityId "mike" in wrongname.yaml)
writeFileSync(join(humansDir(home), "wrongname.yaml"), stringifyYaml(fragment({ entityId: "mike" })));
const proj = projectHumans(home);
expect(proj.ok).toBe(false);
});
});