@fides-anima/fpp-protocol-core 1.0.2

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.
Files changed (101) hide show
  1. package/LICENSE +95 -0
  2. package/README.md +41 -0
  3. package/SKILL.md +348 -0
  4. package/constitution.json +102 -0
  5. package/constitution.yaml +106 -0
  6. package/dist/adoption-disclosure.d.ts +35 -0
  7. package/dist/adoption-disclosure.d.ts.map +1 -0
  8. package/dist/adoption-disclosure.js +67 -0
  9. package/dist/adoption-disclosure.js.map +1 -0
  10. package/dist/adoption.d.ts +56 -0
  11. package/dist/adoption.d.ts.map +1 -0
  12. package/dist/adoption.js +97 -0
  13. package/dist/adoption.js.map +1 -0
  14. package/dist/canonical-json.d.ts +23 -0
  15. package/dist/canonical-json.d.ts.map +1 -0
  16. package/dist/canonical-json.js +60 -0
  17. package/dist/canonical-json.js.map +1 -0
  18. package/dist/capsules.d.ts +63 -0
  19. package/dist/capsules.d.ts.map +1 -0
  20. package/dist/capsules.js +94 -0
  21. package/dist/capsules.js.map +1 -0
  22. package/dist/claims.d.ts +62 -0
  23. package/dist/claims.d.ts.map +1 -0
  24. package/dist/claims.js +99 -0
  25. package/dist/claims.js.map +1 -0
  26. package/dist/digest.d.ts +38 -0
  27. package/dist/digest.d.ts.map +1 -0
  28. package/dist/digest.js +52 -0
  29. package/dist/digest.js.map +1 -0
  30. package/dist/disposition.d.ts +34 -0
  31. package/dist/disposition.d.ts.map +1 -0
  32. package/dist/disposition.js +51 -0
  33. package/dist/disposition.js.map +1 -0
  34. package/dist/emergency-override.d.ts +73 -0
  35. package/dist/emergency-override.d.ts.map +1 -0
  36. package/dist/emergency-override.js +102 -0
  37. package/dist/emergency-override.js.map +1 -0
  38. package/dist/evidence.d.ts +25 -0
  39. package/dist/evidence.d.ts.map +1 -0
  40. package/dist/evidence.js +30 -0
  41. package/dist/evidence.js.map +1 -0
  42. package/dist/freshness.d.ts +41 -0
  43. package/dist/freshness.d.ts.map +1 -0
  44. package/dist/freshness.js +66 -0
  45. package/dist/freshness.js.map +1 -0
  46. package/dist/governance.d.ts +87 -0
  47. package/dist/governance.d.ts.map +1 -0
  48. package/dist/governance.js +94 -0
  49. package/dist/governance.js.map +1 -0
  50. package/dist/identity.d.ts +43 -0
  51. package/dist/identity.d.ts.map +1 -0
  52. package/dist/identity.js +93 -0
  53. package/dist/identity.js.map +1 -0
  54. package/dist/index.d.ts +30 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +30 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/mandates.d.ts +80 -0
  59. package/dist/mandates.d.ts.map +1 -0
  60. package/dist/mandates.js +127 -0
  61. package/dist/mandates.js.map +1 -0
  62. package/dist/merkle.d.ts +31 -0
  63. package/dist/merkle.d.ts.map +1 -0
  64. package/dist/merkle.js +95 -0
  65. package/dist/merkle.js.map +1 -0
  66. package/dist/quorum.d.ts +133 -0
  67. package/dist/quorum.d.ts.map +1 -0
  68. package/dist/quorum.js +128 -0
  69. package/dist/quorum.js.map +1 -0
  70. package/dist/receipts.d.ts +77 -0
  71. package/dist/receipts.d.ts.map +1 -0
  72. package/dist/receipts.js +104 -0
  73. package/dist/receipts.js.map +1 -0
  74. package/dist/steward-authorization.d.ts +200 -0
  75. package/dist/steward-authorization.d.ts.map +1 -0
  76. package/dist/steward-authorization.js +483 -0
  77. package/dist/steward-authorization.js.map +1 -0
  78. package/dist/workspace-profile.d.ts +43 -0
  79. package/dist/workspace-profile.d.ts.map +1 -0
  80. package/dist/workspace-profile.js +91 -0
  81. package/dist/workspace-profile.js.map +1 -0
  82. package/package.json +56 -0
  83. package/src/adoption-disclosure.ts +120 -0
  84. package/src/adoption.ts +152 -0
  85. package/src/canonical-json.ts +70 -0
  86. package/src/capsules.ts +161 -0
  87. package/src/claims.ts +147 -0
  88. package/src/digest.ts +63 -0
  89. package/src/disposition.ts +74 -0
  90. package/src/emergency-override.ts +168 -0
  91. package/src/evidence.ts +48 -0
  92. package/src/freshness.ts +103 -0
  93. package/src/governance.ts +152 -0
  94. package/src/identity.ts +119 -0
  95. package/src/index.ts +270 -0
  96. package/src/mandates.ts +201 -0
  97. package/src/merkle.ts +138 -0
  98. package/src/quorum.ts +193 -0
  99. package/src/receipts.ts +150 -0
  100. package/src/steward-authorization.ts +657 -0
  101. package/src/workspace-profile.ts +134 -0
package/src/digest.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Versioned digests with optional domain separation.
3
+ *
4
+ * V1 entry hashes omit domain prefixes so historical audit chains verify.
5
+ * V2 digests always include a domain label to prevent cross-type reuse.
6
+ */
7
+
8
+ import { sha256 } from "@noble/hashes/sha256";
9
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
10
+ import { canonicalizeV1, canonicalizeV2 } from "./canonical-json.js";
11
+
12
+ /** Well-known digest domains for v2 payloads. */
13
+ export const DIGEST_DOMAINS = {
14
+ entry: "fpp:v2:entry",
15
+ claim: "fpp:v2:claim",
16
+ leaf: "fpp:v2:merkle-leaf",
17
+ node: "fpp:v2:merkle-node",
18
+ receipt: "fpp:v2:receipt",
19
+ capsule: "fpp:v2:capsule",
20
+ evidence: "fpp:v2:evidence",
21
+ adoption: "fpp:v2:adoption",
22
+ quorum: "fpp:v2:quorum",
23
+ mandate: "fpp:v2:mandate",
24
+ } as const;
25
+
26
+ export type DigestDomain =
27
+ (typeof DIGEST_DOMAINS)[keyof typeof DIGEST_DOMAINS] | (string & {});
28
+
29
+ function sha256Hex(data: string): string {
30
+ return bytesToHex(sha256(utf8ToBytes(data)));
31
+ }
32
+
33
+ /**
34
+ * Historical v1 entry digest: SHA-256 of canonicalizeV1(entry without hash).
35
+ */
36
+ export function hashEntryV1(entry: Record<string, unknown>): string {
37
+ const { hash: _ignored, ...rest } = entry;
38
+ void _ignored;
39
+ return sha256Hex(canonicalizeV1(rest));
40
+ }
41
+
42
+ export type DigestInput =
43
+ | { version: 1; value: unknown; domain?: never }
44
+ | { version: 2; value: unknown; domain: DigestDomain };
45
+
46
+ /**
47
+ * Compute a versioned digest. Version 2 requires a domain separator.
48
+ */
49
+ export function digest(input: DigestInput): string {
50
+ if (input.version === 1) {
51
+ if (input.value !== null && typeof input.value === "object" && !Array.isArray(input.value)) {
52
+ return hashEntryV1(input.value as Record<string, unknown>);
53
+ }
54
+ return sha256Hex(canonicalizeV1(input.value));
55
+ }
56
+ if (input.version === 2) {
57
+ if (!input.domain || typeof input.domain !== "string") {
58
+ throw new Error("digest version 2 requires a domain separator");
59
+ }
60
+ return sha256Hex(input.domain + "\0" + canonicalizeV2(input.value));
61
+ }
62
+ throw new Error(`unsupported digest version: ${(input as { version: unknown }).version}`);
63
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Disposition decisions and authorization classes for unattended /
3
+ * operator-present policy resolution and receipt emission.
4
+ */
5
+
6
+ export const DISPOSITION_DECISIONS = [
7
+ "allow",
8
+ "deny",
9
+ "require_approval",
10
+ "abstain",
11
+ "allow_staged",
12
+ "allow_minimal",
13
+ ] as const;
14
+
15
+ export type DispositionDecision = (typeof DISPOSITION_DECISIONS)[number];
16
+
17
+ export const AUTHORIZATION_CLASSES = [
18
+ "mandate",
19
+ "standing-allowlist",
20
+ "emergency",
21
+ "quorum-mandate",
22
+ "abstain",
23
+ "approved",
24
+ "policy-block",
25
+ ] as const;
26
+
27
+ export type AuthorizationClass = (typeof AUTHORIZATION_CLASSES)[number];
28
+
29
+ /**
30
+ * Named authorization-class constants for call sites.
31
+ * Prefer `AUTHZ.*` (or `const authorization = AUTHZ.…; { authorization }`)
32
+ * over `authorization: "<literal>"` so static scanners do not treat
33
+ * AuthorizationClass strings as exposed API-token secret literals.
34
+ */
35
+ export const AUTHZ = {
36
+ mandate: "mandate",
37
+ standingAllowlist: "standing-allowlist",
38
+ emergency: "emergency",
39
+ quorumMandate: "quorum-mandate",
40
+ abstain: "abstain",
41
+ approved: "approved",
42
+ policyBlock: "policy-block",
43
+ } as const satisfies Record<string, AuthorizationClass>;
44
+
45
+ export type DispositionParseResult =
46
+ | { ok: true; disposition: DispositionDecision }
47
+ | { ok: false; error: string };
48
+
49
+ export function isDispositionDecision(
50
+ value: unknown,
51
+ ): value is DispositionDecision {
52
+ return (
53
+ typeof value === "string" &&
54
+ (DISPOSITION_DECISIONS as readonly string[]).includes(value)
55
+ );
56
+ }
57
+
58
+ export function isAuthorizationClass(
59
+ value: unknown,
60
+ ): value is AuthorizationClass {
61
+ return (
62
+ typeof value === "string" &&
63
+ (AUTHORIZATION_CLASSES as readonly string[]).includes(value)
64
+ );
65
+ }
66
+
67
+ export function parseDispositionDecision(
68
+ input: unknown,
69
+ ): DispositionParseResult {
70
+ if (!isDispositionDecision(input)) {
71
+ return { ok: false, error: "invalid DispositionDecision" };
72
+ }
73
+ return { ok: true, disposition: input };
74
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Signed emergency override grant — mandate-shaped but mandate-separate.
3
+ *
4
+ * Stewards only for v1: agent-to-agent (peer) escalation without steward
5
+ * involvement is a materially larger trust decision; not an oversight.
6
+ */
7
+
8
+ import { Type, type Static } from "@sinclair/typebox";
9
+ import { Value } from "@sinclair/typebox/value";
10
+ import { canonicalizeV2 } from "./canonical-json.js";
11
+ import { verifySignature } from "./identity.js";
12
+
13
+ export const SignedEmergencyOverrideV1Schema = Type.Object(
14
+ {
15
+ schemaVersion: Type.Literal(1),
16
+ overrideId: Type.String({ minLength: 1 }),
17
+ issuerId: Type.String({ minLength: 1 }),
18
+ publicKey: Type.String({ minLength: 1 }),
19
+ signature: Type.String({ minLength: 1 }),
20
+ scope: Type.Object(
21
+ {
22
+ classifications: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
23
+ capabilities: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
24
+ },
25
+ { additionalProperties: false },
26
+ ),
27
+ budgets: Type.Object(
28
+ {
29
+ maxActions: Type.Optional(Type.Integer({ minimum: 0 })),
30
+ remainingActions: Type.Optional(Type.Integer({ minimum: 0 })),
31
+ },
32
+ { additionalProperties: false },
33
+ ),
34
+ validFrom: Type.String({ minLength: 1 }),
35
+ validTo: Type.String({ minLength: 1 }),
36
+ evidenceRef: Type.String({ minLength: 1 }),
37
+ revoked: Type.Optional(Type.Boolean()),
38
+ },
39
+ { additionalProperties: false },
40
+ );
41
+
42
+ export type SignedEmergencyOverrideV1 = Static<
43
+ typeof SignedEmergencyOverrideV1Schema
44
+ >;
45
+
46
+ /** Mutable runtime counters — never part of the signed grant payload. */
47
+ export type EmergencyOverrideLedgerEntry = {
48
+ remainingActions?: number;
49
+ revoked?: boolean;
50
+ };
51
+
52
+ /**
53
+ * Shared on-disk emergency override store shape.
54
+ * `ledgers` is additive; absent keys mean unlimited budget / not revoked.
55
+ */
56
+ export type EmergencyOverrideStoreFile = {
57
+ schemaVersion: 1;
58
+ overrides: SignedEmergencyOverrideV1[];
59
+ ledgers?: Record<string, EmergencyOverrideLedgerEntry>;
60
+ };
61
+
62
+ export type EmergencyOverrideParseResult =
63
+ | { ok: true; override: SignedEmergencyOverrideV1 }
64
+ | { ok: false; error: string };
65
+
66
+ export type EmergencyOverrideValidityPolicy = {
67
+ /** Verifier clock — not signer-controlled. */
68
+ nowMs: number;
69
+ };
70
+
71
+ export type EmergencyOverrideValidity = {
72
+ valid: boolean;
73
+ reason: string;
74
+ };
75
+
76
+ export function parseSignedEmergencyOverride(
77
+ input: unknown,
78
+ ): EmergencyOverrideParseResult {
79
+ if (!Value.Check(SignedEmergencyOverrideV1Schema, input)) {
80
+ const errors = [...Value.Errors(SignedEmergencyOverrideV1Schema, input)];
81
+ return {
82
+ ok: false,
83
+ error:
84
+ errors[0] !== undefined
85
+ ? `${errors[0].path}: ${errors[0].message}`
86
+ : "invalid SignedEmergencyOverrideV1",
87
+ };
88
+ }
89
+ return { ok: true, override: input };
90
+ }
91
+
92
+ /**
93
+ * Temporal / revocation check for a schema-valid emergency override.
94
+ * Budget debit and signature verification live in the enforcement store.
95
+ */
96
+ export function validateEmergencyOverrideValidity(
97
+ override: SignedEmergencyOverrideV1,
98
+ policy: EmergencyOverrideValidityPolicy,
99
+ ): EmergencyOverrideValidity {
100
+ if (override.revoked === true) {
101
+ return { valid: false, reason: "emergency override revoked" };
102
+ }
103
+ const from = Date.parse(override.validFrom);
104
+ const to = Date.parse(override.validTo);
105
+ if (Number.isNaN(from) || Number.isNaN(to)) {
106
+ return { valid: false, reason: "validFrom/validTo must be ISO-8601" };
107
+ }
108
+ if (to <= from) {
109
+ return { valid: false, reason: "validTo must be after validFrom" };
110
+ }
111
+ if (policy.nowMs < from) {
112
+ return {
113
+ valid: false,
114
+ reason: "emergency override not yet valid (validFrom)",
115
+ };
116
+ }
117
+ if (policy.nowMs > to) {
118
+ return { valid: false, reason: "emergency override expired" };
119
+ }
120
+ return { valid: true, reason: "ok" };
121
+ }
122
+
123
+ /**
124
+ * Canonical unsigned payload for emergency override signatures.
125
+ * Excludes `signature`, `revoked`, and `budgets.remainingActions` so debit/revoke
126
+ * can mutate an unsigned ledger without invalidating the grant.
127
+ */
128
+ export function emergencyOverrideSigningFields(
129
+ override: SignedEmergencyOverrideV1,
130
+ ): Record<string, unknown> {
131
+ const {
132
+ signature: _signature,
133
+ revoked: _revoked,
134
+ budgets,
135
+ ...rest
136
+ } = override;
137
+ void _signature;
138
+ void _revoked;
139
+ const signingBudgets: Record<string, unknown> = {};
140
+ if (budgets.maxActions !== undefined) {
141
+ signingBudgets.maxActions = budgets.maxActions;
142
+ }
143
+ return {
144
+ ...rest,
145
+ budgets: signingBudgets,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Verify an emergency override signature over `emergencyOverrideSigningFields`.
151
+ */
152
+ export function verifyEmergencyOverrideSignature(
153
+ override: SignedEmergencyOverrideV1,
154
+ ): boolean {
155
+ if (!override.publicKey || !override.signature) {
156
+ return false;
157
+ }
158
+ const message = Buffer.from(
159
+ canonicalizeV2(emergencyOverrideSigningFields(override)),
160
+ "utf8",
161
+ );
162
+ const sigBytes = Buffer.from(override.signature, "hex");
163
+ const pubBytes = Buffer.from(override.publicKey, "hex");
164
+ if (sigBytes.length !== 64 || pubBytes.length !== 32) {
165
+ return false;
166
+ }
167
+ return verifySignature(message, sigBytes, pubBytes);
168
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Append-only evidence envelopes with optional correction references.
3
+ * Global trust scores are intentionally excluded.
4
+ */
5
+
6
+ import { Type, type Static } from "@sinclair/typebox";
7
+ import { Value } from "@sinclair/typebox/value";
8
+ import { CLAIM_CLASSES } from "./claims.js";
9
+
10
+ export const EvidenceEnvelopeV1Schema = Type.Object(
11
+ {
12
+ schemaVersion: Type.Literal(1),
13
+ evidenceId: Type.String({ minLength: 1 }),
14
+ evidenceClass: Type.Union([
15
+ Type.Literal("claim"),
16
+ Type.Literal("receipt"),
17
+ Type.Literal("capsule"),
18
+ Type.Literal("adoption"),
19
+ Type.Literal("annotation"),
20
+ ]),
21
+ claimClass: Type.Optional(
22
+ Type.Union(
23
+ CLAIM_CLASSES.map((c) => Type.Literal(c)) as [
24
+ ReturnType<typeof Type.Literal<(typeof CLAIM_CLASSES)[number]>>,
25
+ ...ReturnType<typeof Type.Literal<(typeof CLAIM_CLASSES)[number]>>[],
26
+ ],
27
+ ),
28
+ ),
29
+ payloadDigest: Type.String({ minLength: 1 }),
30
+ recordedAt: Type.String({ minLength: 1 }),
31
+ corrects: Type.Optional(Type.String({ minLength: 1 })),
32
+ annotation: Type.Optional(Type.String()),
33
+ },
34
+ { additionalProperties: false },
35
+ );
36
+
37
+ export type EvidenceEnvelopeV1 = Static<typeof EvidenceEnvelopeV1Schema>;
38
+
39
+ export type EvidenceParseResult =
40
+ | { ok: true; envelope: EvidenceEnvelopeV1 }
41
+ | { ok: false; error: string };
42
+
43
+ export function parseEvidenceEnvelope(input: unknown): EvidenceParseResult {
44
+ if (!Value.Check(EvidenceEnvelopeV1Schema, input)) {
45
+ return { ok: false, error: "invalid EvidenceEnvelopeV1" };
46
+ }
47
+ return { ok: true, envelope: input };
48
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Freshness and replay-key contracts for signed envelopes.
3
+ *
4
+ * Policy limits (max lifetime, clock skew) are verifier-controlled inputs.
5
+ * This module does not implement replay-cache persistence (Plan 4).
6
+ */
7
+
8
+ import { Type, type Static } from "@sinclair/typebox";
9
+ import { Value } from "@sinclair/typebox/value";
10
+ import { sha256 } from "@noble/hashes/sha256";
11
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
12
+ import { canonicalizeV2 } from "./canonical-json.js";
13
+
14
+ export const FreshnessEnvelopeSchema = Type.Object(
15
+ {
16
+ audience: Type.String({ minLength: 1 }),
17
+ challenge: Type.String({ minLength: 1 }),
18
+ issuedAt: Type.String({ minLength: 1 }),
19
+ expiresAt: Type.String({ minLength: 1 }),
20
+ },
21
+ { additionalProperties: false },
22
+ );
23
+
24
+ export type FreshnessEnvelope = Static<typeof FreshnessEnvelopeSchema>;
25
+
26
+ export type FreshnessPolicy = {
27
+ /** Maximum allowed (expiresAt - issuedAt) in milliseconds. */
28
+ maxLifetimeMs: number;
29
+ /** Allowed clock skew when comparing issuedAt/expiresAt to now. */
30
+ allowedClockSkewMs: number;
31
+ /** Verifier clock — not signer-controlled. */
32
+ nowMs: number;
33
+ };
34
+
35
+ export type FreshnessValidation = {
36
+ valid: boolean;
37
+ reason: string;
38
+ };
39
+
40
+ export type FreshnessParseResult =
41
+ | { ok: true; envelope: FreshnessEnvelope }
42
+ | { ok: false; error: string };
43
+
44
+ export function parseFreshnessEnvelope(input: unknown): FreshnessParseResult {
45
+ if (!Value.Check(FreshnessEnvelopeSchema, input)) {
46
+ const errors = [...Value.Errors(FreshnessEnvelopeSchema, input)];
47
+ return {
48
+ ok: false,
49
+ error:
50
+ errors[0] !== undefined
51
+ ? `${errors[0].path}: ${errors[0].message}`
52
+ : "invalid freshness envelope",
53
+ };
54
+ }
55
+ return { ok: true, envelope: input };
56
+ }
57
+
58
+ export function validateFreshness(
59
+ envelope: FreshnessEnvelope,
60
+ policy: FreshnessPolicy,
61
+ ): FreshnessValidation {
62
+ const issued = Date.parse(envelope.issuedAt);
63
+ const expires = Date.parse(envelope.expiresAt);
64
+ if (Number.isNaN(issued) || Number.isNaN(expires)) {
65
+ return { valid: false, reason: "issuedAt/expiresAt must be ISO-8601" };
66
+ }
67
+ if (expires <= issued) {
68
+ return { valid: false, reason: "expiresAt must be after issuedAt" };
69
+ }
70
+ const lifetime = expires - issued;
71
+ if (lifetime > policy.maxLifetimeMs) {
72
+ return {
73
+ valid: false,
74
+ reason: "lifetime exceeds verifier policy maxLifetimeMs",
75
+ };
76
+ }
77
+ if (issued > policy.nowMs + policy.allowedClockSkewMs) {
78
+ return { valid: false, reason: "issuedAt is in the future beyond skew" };
79
+ }
80
+ if (expires < policy.nowMs - policy.allowedClockSkewMs) {
81
+ return { valid: false, reason: "envelope expired" };
82
+ }
83
+ return { valid: true, reason: "fresh" };
84
+ }
85
+
86
+ /**
87
+ * Deterministic replay key over audience + challenge + issuedAt.
88
+ * Persistence/dedup is deferred to Plan 4.
89
+ */
90
+ export function buildReplayKey(envelope: FreshnessEnvelope): string {
91
+ return bytesToHex(
92
+ sha256(
93
+ utf8ToBytes(
94
+ "fpp:v2:replay\0" +
95
+ canonicalizeV2({
96
+ audience: envelope.audience,
97
+ challenge: envelope.challenge,
98
+ issuedAt: envelope.issuedAt,
99
+ }),
100
+ ),
101
+ ),
102
+ );
103
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Gateway governance transition contracts: modes, epochs, and audited events.
3
+ *
4
+ * These schemas define the voluntary constitutional-layer state machine
5
+ * (enabled → draining → disabled) without implying gateway enforcement is shipped.
6
+ */
7
+
8
+ import { Type, type Static } from "@sinclair/typebox";
9
+ import { Value } from "@sinclair/typebox/value";
10
+
11
+ export const GOVERNANCE_MODES = ["enabled", "draining", "disabled"] as const;
12
+
13
+ export type GovernanceMode = (typeof GOVERNANCE_MODES)[number];
14
+
15
+ export const GOVERNANCE_EVENT_KINDS = [
16
+ "governance-enabled",
17
+ "governance-disabled",
18
+ ] as const;
19
+
20
+ export type GovernanceEventKind = (typeof GOVERNANCE_EVENT_KINDS)[number];
21
+
22
+ /** SHA-256 hex digest (64 lowercase or uppercase hex chars). */
23
+ const HexDigest64 = Type.String({ pattern: "^[0-9a-fA-F]{64}$" });
24
+
25
+ export const GovernanceEpochSchema = Type.Integer({ minimum: 0 });
26
+
27
+ export const GovernanceModeSchema = Type.Union(
28
+ GOVERNANCE_MODES.map((m) => Type.Literal(m)) as [
29
+ ReturnType<typeof Type.Literal<(typeof GOVERNANCE_MODES)[number]>>,
30
+ ...ReturnType<typeof Type.Literal<(typeof GOVERNANCE_MODES)[number]>>[],
31
+ ],
32
+ );
33
+
34
+ export const GovernanceStateV1Schema = Type.Object(
35
+ {
36
+ schemaVersion: Type.Literal(1),
37
+ mode: GovernanceModeSchema,
38
+ epoch: GovernanceEpochSchema,
39
+ },
40
+ { additionalProperties: false },
41
+ );
42
+
43
+ export type GovernanceStateV1 = Static<typeof GovernanceStateV1Schema>;
44
+
45
+ export const GovernanceActorSchema = Type.Object(
46
+ {
47
+ role: Type.String({ minLength: 1 }),
48
+ id: Type.String({ minLength: 1 }),
49
+ },
50
+ { additionalProperties: false },
51
+ );
52
+
53
+ export const GovernanceEventSignatureSchema = Type.Object(
54
+ {
55
+ alg: Type.String({ minLength: 1 }),
56
+ keyId: Type.String({ minLength: 1 }),
57
+ sig: Type.String({ minLength: 1 }),
58
+ },
59
+ { additionalProperties: false },
60
+ );
61
+
62
+ /**
63
+ * Tamper-evident governance enable/disable event.
64
+ * `epoch` is the published epoch after the transition; `previousMode` is the
65
+ * mode immediately before durable publication of this event.
66
+ */
67
+ export const GovernanceEventV1Schema = Type.Object(
68
+ {
69
+ schemaVersion: Type.Literal(1),
70
+ kind: Type.Union([
71
+ Type.Literal("governance-enabled"),
72
+ Type.Literal("governance-disabled"),
73
+ ]),
74
+ eventId: Type.String({ minLength: 1 }),
75
+ ts: Type.String({ minLength: 1 }),
76
+ epoch: GovernanceEpochSchema,
77
+ previousMode: GovernanceModeSchema,
78
+ mode: GovernanceModeSchema,
79
+ actor: GovernanceActorSchema,
80
+ constitutionHash: HexDigest64,
81
+ policyEngineVersion: Type.String({ minLength: 1 }),
82
+ prevHash: HexDigest64,
83
+ entryHash: HexDigest64,
84
+ reason: Type.Optional(Type.String({ minLength: 1 })),
85
+ signature: GovernanceEventSignatureSchema,
86
+ },
87
+ { additionalProperties: false },
88
+ );
89
+
90
+ export type GovernanceEventV1 = Static<typeof GovernanceEventV1Schema>;
91
+
92
+ export type GovernanceEpochParseResult =
93
+ | { ok: true; epoch: number }
94
+ | { ok: false; error: string };
95
+
96
+ export type GovernanceStateParseResult =
97
+ | { ok: true; state: GovernanceStateV1 }
98
+ | { ok: false; error: string };
99
+
100
+ export type GovernanceEventParseResult =
101
+ | { ok: true; event: GovernanceEventV1 }
102
+ | { ok: false; error: string };
103
+
104
+ export function isGovernanceMode(value: unknown): value is GovernanceMode {
105
+ return (
106
+ typeof value === "string" &&
107
+ (GOVERNANCE_MODES as readonly string[]).includes(value)
108
+ );
109
+ }
110
+
111
+ export function parseGovernanceEpoch(
112
+ input: unknown,
113
+ ): GovernanceEpochParseResult {
114
+ if (!Value.Check(GovernanceEpochSchema, input)) {
115
+ return { ok: false, error: "invalid GovernanceEpoch" };
116
+ }
117
+ return { ok: true, epoch: input };
118
+ }
119
+
120
+ export function parseGovernanceState(
121
+ input: unknown,
122
+ ): GovernanceStateParseResult {
123
+ if (!Value.Check(GovernanceStateV1Schema, input)) {
124
+ return { ok: false, error: "invalid GovernanceStateV1" };
125
+ }
126
+ return { ok: true, state: input };
127
+ }
128
+
129
+ /**
130
+ * Parse a governance event and enforce kind ↔ terminal mode consistency:
131
+ * disabled events publish mode "disabled"; enabled events publish "enabled".
132
+ */
133
+ export function parseGovernanceEvent(
134
+ input: unknown,
135
+ ): GovernanceEventParseResult {
136
+ if (!Value.Check(GovernanceEventV1Schema, input)) {
137
+ return { ok: false, error: "invalid GovernanceEventV1" };
138
+ }
139
+ if (input.kind === "governance-disabled" && input.mode !== "disabled") {
140
+ return {
141
+ ok: false,
142
+ error: "governance-disabled event must publish mode disabled",
143
+ };
144
+ }
145
+ if (input.kind === "governance-enabled" && input.mode !== "enabled") {
146
+ return {
147
+ ok: false,
148
+ error: "governance-enabled event must publish mode enabled",
149
+ };
150
+ }
151
+ return { ok: true, event: input };
152
+ }