fix(sema,morfo): S-33 — el eje de intent declara tres valores y ahora implementa tres

`IntentOptionalFamily` era `Exclude<SemaFamily, IntentRequiredFamily>`: una
derivacion de mundo abierto —«todo lo que no es required»— que se tragaba el
tercer valor en silencio. Una familia marcada `'forbidden'` caia en el cubo
OPCIONAL, donde el intent esta PERMITIDO, asi que el dia que el libro endurezca
`contact` (lo anticipa el propio docblock) se editaria una linea del const
creyendo cerrada la puerta y `contact + threat` seguiria compilando.

Los tres cubos se derivan ahora POSITIVAMENTE, uno por valor del eje, y detras
va la rama `{ family: IntentForbiddenFamily; intent?: never }` en `SemaEvent` y
en `MorfoEventSemantic`. Coste hoy: CERO, medido — `IntentForbiddenFamily` es
`never`, asi que la rama esta deshabitada y no toca la puerta de compilacion de
los 172 morfos. Lo que cambia es que la promesa del canon («editing the const
reshapes the discriminated unions») pasa a ser cierta.

Runtime: `isSemaEvent` gana la condicion simetrica. Y aparecio una arista que la
ficha del audit no vio — leer `SEMA_FAMILY_POLICY[f].intentRequirement` directo
ESTRECHA a `'required' | 'optional'` (no hay familia forbidden hoy), asi que la
comparacion no compila: «no overlap». Anotar el `const` no basta, el flujo lo
vuelve a estrechar. De ahi `intentRequirementOf(family)`, cuyo tipo de retorno ES
el eje; queda documentado en su docblock para que nadie lo deshaga.

⚠️ Desviacion DECLARADA de la receta firmada: NO se toca `validation.ts`. La
receta pedia la condicion simetrica «en los dos guardianes», pero medido antes de
escribir nada, la rama de politica que ya existe alli es INALCANZABLE:
`validateSemaEvent({family:'commit'})` lanza el mensaje generico «is not a valid
canonical semantic event», porque `isSemaEvent` rechaza aguas arriba. Anadir la
simetrica seria anadir mas codigo muerto. Va como hallazgo aparte.

Test nuevo `intent-policy.test.ts`: el probe de tipos con una replica ENDURECIDA
del const (tres `@ts-expect-error`, uno de ellos el que ayer se quedaba sin
consumir), la particion de los tres cubos contra el const, y el guard de runtime
derivado del const — vacuo hoy A PROPOSITO, vivo el dia que una familia pase a
`'forbidden'` sin que nadie tenga que acordarse.

Verificado por METODO, no por cifra: base aislada con stash = 69 errores, con mis
cambios 69, diff VACIO. sema+morfo 537 ✓ (532 + 5 nuevos) · contracts 6 fallos =
los 6 ajenos de la base · prettier limpio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent cb1fd54d10
commit 1a174d5a6e

@ -35,6 +35,7 @@ import type {
SemaFamily,
IntentExpectedFamily,
IntentOptionalFamily,
IntentForbiddenFamily,
SignalPersistence
} from '../sema/types';
import type { SemaChannelId, SemaSignatureOverride } from '../sema/channels';
@ -439,6 +440,17 @@ export type MorfoEventSemantic = (
sequence?: MorfoEventSequence;
allowedFamilies?: readonly SemaFamily[];
}
| {
// `IntentForbiddenFamily` is `never` today, so this branch is
// uninhabited and costs nothing — it activates by itself the day the
// policy const marks a family `'forbidden'` (audit S-33).
family: IntentForbiddenFamily;
target: PartRef;
intent?: never;
verb?: string;
sequence?: MorfoEventSequence;
allowedFamilies?: readonly SemaFamily[];
}
) & {
/**
* Per-event channel allowlist (resolution layer 4). When set, REPLACES

@ -1,4 +1,5 @@
import {
intentRequirementOf,
SEMA_FAMILY_POLICY,
type SemaActionEvent,
type SemaEvent,
@ -100,13 +101,21 @@ export function isIntentBinding(value: unknown): value is IntentBinding {
export function isSemaEvent(value: unknown): value is SemaEvent {
if (!isRecord(value) || !isSemaFamily(value.family)) return false;
const requirement = SEMA_FAMILY_POLICY[value.family].intentRequirement;
// Through the accessor, not the const: a direct read narrows to the values in
// use today and the `'forbidden'` gate below would not compile.
const requirement = intentRequirementOf(value.family);
const intentValue = 'intent' in value ? value.intent : undefined;
if (intentValue === undefined) {
return requirement !== 'required';
}
// The symmetric gate: a family the policy marks `'forbidden'` MUST NOT carry
// intent. Without this the runtime mirrored the type-level hole S-33 fixed —
// the check only ever looked at `'required'`, so the third value of the axis
// was unenforced on both sides.
if (requirement === 'forbidden') return false;
return isIntent(intentValue) || isIntentBinding(intentValue);
}

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { isSemaEvent } from './event';
import { validateSemaEvent } from './validation';
import {
intentRequirementOf,
SEMA_FAMILY_POLICY,
type IntentForbiddenFamily,
type IntentOptionalFamily,
type IntentRequiredFamily,
type SemaFamily
} from './types';
const requirementOf = (family: string) => intentRequirementOf(family as SemaFamily);
/**
* The intent policy has THREE values and, until 2026-08-13, two of them were
* implemented. `IntentOptionalFamily` was `Exclude<SemaFamily,
* IntentRequiredFamily>` — an open-world derivation ("everything that is not
* required") that quietly filed a `'forbidden'` family under OPTIONAL, where
* intent is allowed. The runtime mirrored the hole: both guards only ever
* compared against `'required'` (audit S-33).
*
* No family uses `'forbidden'` today (`CANON.md` §4 keeps it reserved), so the
* only way to test the shape is to derive the buckets from a HARDENED replica
* of the const and check what the compiler does with them — which is exactly
* what the fix is about: the const is the source, and editing it must reshape
* the unions by itself.
*/
describe('intent policy — the three buckets', () => {
// ── Type level: the derivation shape, against a hardened policy ──────────
//
// These probes fail at `npm run check`, not in this runner: an unused
// `@ts-expect-error` is a compile error (TS2578). Reverting the positive
// derivation to `Exclude` makes the first one stop erroring, and the
// directive above it goes unused → check turns red.
const HARDENED = {
contact: { intentRequirement: 'forbidden' },
commit: { intentRequirement: 'required' },
signal: { intentRequirement: 'required' },
handle: { intentRequirement: 'optional' },
emerge: { intentRequirement: 'optional' },
shift: { intentRequirement: 'optional' },
sustain: { intentRequirement: 'optional' },
delegate: { intentRequirement: 'optional' }
} as const satisfies Record<SemaFamily, { intentRequirement: string }>;
type HardRequired = {
[K in SemaFamily]: (typeof HARDENED)[K]['intentRequirement'] extends 'required' ? K : never;
}[SemaFamily];
type HardOptional = {
[K in SemaFamily]: (typeof HARDENED)[K]['intentRequirement'] extends 'optional' ? K : never;
}[SemaFamily];
type HardForbidden = {
[K in SemaFamily]: (typeof HARDENED)[K]['intentRequirement'] extends 'forbidden' ? K : never;
}[SemaFamily];
type HardEvent =
| { family: HardOptional; intent?: string }
| { family: HardRequired; intent: string }
| { family: HardForbidden; intent?: never };
it('a forbidden family cannot carry intent, and stays usable without it', () => {
// @ts-expect-error contact is forbidden in HARDENED — intent must not compile
const withIntent: HardEvent = { family: 'contact', intent: 'threat' };
const bare: HardEvent = { family: 'contact' };
// @ts-expect-error a required family still cannot drop its intent
const missing: HardEvent = { family: 'commit' };
expect([withIntent, bare, missing]).toHaveLength(3);
});
// ── The real types, as they stand ────────────────────────────────────────
it('the three buckets partition the families, and forbidden is empty today', () => {
const required: IntentRequiredFamily[] = ['commit', 'signal'];
const optional: IntentOptionalFamily[] = [
'contact',
'handle',
'emerge',
'shift',
'sustain',
'delegate'
];
// `IntentForbiddenFamily` is `never`: an empty array is the only value
// this type accepts, which is the assertion.
const forbidden: IntentForbiddenFamily[] = [];
expect([...required, ...optional, ...forbidden].sort()).toEqual(
Object.keys(SEMA_FAMILY_POLICY).sort()
);
expect(Object.keys(SEMA_FAMILY_POLICY).filter((f) => requirementOf(f) === 'forbidden')).toEqual(
[]
);
});
// ── Runtime: derived from the const, so it activates by itself ───────────
it('the runtime guard rejects intent on any family the policy forbids', () => {
const forbidden = Object.keys(SEMA_FAMILY_POLICY).filter(
(family) => requirementOf(family) === 'forbidden'
);
// Vacuous today ON PURPOSE — `forbidden` is empty until the book hardens
// a family, and then this covers it without anyone remembering to.
for (const family of forbidden) {
expect(isSemaEvent({ family })).toBe(true);
expect(isSemaEvent({ family, intent: 'threat' })).toBe(false);
expect(() => validateSemaEvent({ family } as never)).not.toThrow();
expect(() => validateSemaEvent({ family, intent: 'threat' } as never)).toThrow();
}
// The live half of the same gate, for contrast: `required` still bites.
expect(isSemaEvent({ family: 'commit' })).toBe(false);
expect(isSemaEvent({ family: 'commit', intent: 'affirm' })).toBe(true);
});
});

@ -84,8 +84,30 @@ export const SEMA_FAMILY_POLICY = {
>;
/**
* Families whose policy requires `intent` at the type level. Derived from
* `SEMA_FAMILY_POLICY[K].intentRequirement === 'required'`.
* Read a family's requirement through the AXIS type — the only correct way at
* runtime. `SEMA_FAMILY_POLICY` is `as const`, so a direct read narrows to the
* values in USE today (`'required' | 'optional'`) and any comparison against
* `'forbidden'` becomes a "no overlap" compile error. That error says the third
* value has no instances YET, not that the branch guarding it is wrong — and an
* annotated `const` does not help, because control flow narrows it back to the
* initialiser. Hence a function: its return type is the axis, full stop.
*/
export function intentRequirementOf(family: SemaFamily): IntentRequirement {
return SEMA_FAMILY_POLICY[family].intentRequirement;
}
/**
* The three buckets of `intentRequirement`, each derived POSITIVELY from
* `SEMA_FAMILY_POLICY`. One bucket per value of the axis — that is the point.
*
* `IntentOptionalFamily` used to be `Exclude<SemaFamily, IntentRequiredFamily>`,
* an open-world derivation ("everything that is not required") which swallowed
* the third value in silence: a family marked `'forbidden'` landed in the
* OPTIONAL bucket, where intent is allowed. Measured with tsc before the fix —
* flipping `contact` to `'forbidden'` left `{ family: 'contact', intent:
* 'threat' }` compiling, so the `@ts-expect-error` guarding it went unused
* (audit S-33). Deriving each bucket from the value it names is what makes the
* const's promise true: edit the policy and the unions reshape themselves.
*/
export type IntentRequiredFamily = {
[K in SemaFamily]: (typeof SEMA_FAMILY_POLICY)[K]['intentRequirement'] extends 'required'
@ -99,7 +121,24 @@ export type IntentRequiredFamily = {
*/
export type IntentExpectedFamily = IntentRequiredFamily;
export type IntentOptionalFamily = Exclude<SemaFamily, IntentRequiredFamily>;
export type IntentOptionalFamily = {
[K in SemaFamily]: (typeof SEMA_FAMILY_POLICY)[K]['intentRequirement'] extends 'optional'
? K
: never;
}[SemaFamily];
/**
* Families that MUST NOT declare intent. `never` today — no family uses
* `'forbidden'` (`CANON.md` §4 keeps it reserved) — so the branch it feeds is
* uninhabited and costs nothing. It exists so that the day the book hardens a
* family's prohibition, editing ONE line of the const closes the door by
* itself instead of degrading to "optional" without a word.
*/
export type IntentForbiddenFamily = {
[K in SemaFamily]: (typeof SEMA_FAMILY_POLICY)[K]['intentRequirement'] extends 'forbidden'
? K
: never;
}[SemaFamily];
export type SemaMode = 'blocking' | 'advisory';
@ -298,6 +337,10 @@ export type SemaEvent = (
family: IntentExpectedFamily;
intent: Intent | IntentBinding;
}
| {
family: IntentForbiddenFamily;
intent?: never;
}
) &
SemaEventExtensions;

Loading…
Cancel
Save

Powered by TurnKey Linux.