diff --git a/packages/adf/src/__tests__/content-classifier.test.ts b/packages/adf/src/__tests__/content-classifier.test.ts index 13730c9..046dac7 100644 --- a/packages/adf/src/__tests__/content-classifier.test.ts +++ b/packages/adf/src/__tests__/content-classifier.test.ts @@ -184,6 +184,38 @@ describe('meta-comment filtering', () => { }); }); +// ============================================================================ +// Heading-level STAY — operational/session-protocol headings (#198) +// ============================================================================ + +describe('heading-level STAY for operational headings (#198)', () => { + it('returns STAY for imperative items under "Session Registration"', () => { + const result = classifyElement(rule('ALWAYS run: stackd register', 'imperative'), 'Session Registration'); + expect(result.decision).toBe('STAY'); + expect(result.reason).toContain('Operational'); + }); + + it('returns STAY for items under "Session Protocol"', () => { + const result = classifyElement(rule('Submit the job via stackd submit', 'neutral'), 'Session Protocol'); + expect(result.decision).toBe('STAY'); + }); + + it('returns STAY for items under "AEGIS Task Provenance"', () => { + const result = classifyElement(prose('Record provenance in AEGIS.'), 'AEGIS Task Provenance'); + expect(result.decision).toBe('STAY'); + }); + + it('returns STAY for items under "Receipt" heading', () => { + const result = classifyElement(prose('Attach stackd receipt to PR.'), 'Receipt'); + expect(result.decision).toBe('STAY'); + }); + + it('does NOT suppress migration for regular domain headings', () => { + const result = classifyElement(rule('NEVER commit secrets', 'imperative'), 'API Endpoints', triggerMap); + expect(result.decision).toBe('MIGRATE'); + }); +}); + // ============================================================================ // Table-block classification (#51a) // ============================================================================ diff --git a/packages/adf/src/content-classifier.ts b/packages/adf/src/content-classifier.ts index 5fe347d..4de73ae 100644 --- a/packages/adf/src/content-classifier.ts +++ b/packages/adf/src/content-classifier.ts @@ -92,6 +92,16 @@ const META_PATTERNS: RegExp[] = [ /\bwhen (?:stackbilt)?charter is bootstrapped/i, ]; +// Headings that signal operational/session-protocol content. Items under these +// headings describe runtime procedures, not project domain rules (#198). +const STAY_HEADING_PATTERNS: RegExp[] = [ + /^session\b/i, + /\bprotocol\b/i, + /\breceipt\b/i, + /\bprovenance\b/i, + /\bregistration\b/i, +]; + // ============================================================================ // Classification Helpers // ============================================================================ @@ -249,6 +259,19 @@ export function classifyElement( routingTrace = { headingModule, phraseOverride, candidateScores: scores }; } + // Heading-level STAY: operational/session-protocol headings (#198) + const headingLower = heading.toLowerCase(); + if (STAY_HEADING_PATTERNS.some(p => p.test(headingLower))) { + return { + decision: 'STAY', + targetSection: 'CONTEXT', + targetModule: module, + weight: 'advisory', + reason: 'Operational/session-protocol heading — stay in vendor file', + routingTrace, + }; + } + // Check STAY patterns first if (matchesStayPattern(text, config?.stayPatterns)) { return { diff --git a/packages/cli/src/__tests__/integration/vendor-bloat.test.ts b/packages/cli/src/__tests__/integration/vendor-bloat.test.ts index c4cca08..392477c 100644 --- a/packages/cli/src/__tests__/integration/vendor-bloat.test.ts +++ b/packages/cli/src/__tests__/integration/vendor-bloat.test.ts @@ -303,4 +303,50 @@ describe('vendor bloat pipeline (integration)', () => { // Environment should be preserved expect(claudeContent).toContain('## Environment'); }); + + it('adf tidy preserves Session/Protocol sections verbatim and does not route them to core.adf (#198)', async () => { + const tmp = makeTempDir('session-retain'); + writeFixtureRepo(tmp); + process.chdir(tmp); + + // CLAUDE.md with a thin pointer + operational protocol sections + bloat + fs.writeFileSync(path.join(tmp, 'CLAUDE.md'), `# CLAUDE.md + +> **DO NOT add rules, constraints, or context to this file.** +> This file is auto-managed by Charter. All project rules live in \`.ai/\`. +> New rules should be added to the appropriate \`.ai/*.adf\` module. +> See \`.ai/manifest.adf\` for the module routing manifest. + +## Environment +- Node 20 +- pnpm 9 + +## Session Registration +- Run: register --session +- Submit the job via session CLI + +## Session Protocol +- Always confirm receipt after submit + +## Architecture +- NEVER bypass the repository layer +- ALWAYS validate inputs at the boundary +`); + + // Apply tidy + const tidy = await captureJson(adfTidyCommand, jsonOptions, []); + const tidyResult = tidy.output as { totalExtracted: number }; + expect(tidyResult.totalExtracted).toBeGreaterThan(0); // Architecture items extracted + + // Session and Protocol sections must survive in CLAUDE.md + const after = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf-8'); + expect(after).toContain('## Session Registration'); + expect(after).toContain('## Session Protocol'); + expect(after).toContain('register --session'); + expect(after).toContain('confirm receipt'); + + // Architecture bloat must NOT survive in CLAUDE.md + expect(after).not.toContain('## Architecture'); + expect(after).not.toContain('bypass the repository layer'); + }); }); diff --git a/packages/cli/src/commands/adf-tidy.ts b/packages/cli/src/commands/adf-tidy.ts index e8c53f8..f6109bd 100644 --- a/packages/cli/src/commands/adf-tidy.ts +++ b/packages/cli/src/commands/adf-tidy.ts @@ -265,6 +265,41 @@ function analyzeVendorFile( * Any content before the first H2 that isn't part of the pointer is also bloat. * The ## Environment section and its items are legitimate retained content. */ + +/** True for H2 headings that should stay in the vendor file verbatim (#198). */ +function isRetainedHeading(trimmedLine: string): boolean { + return ( + /^## (Environment|Module Index)$/.test(trimmedLine) || + /^## Session\b/i.test(trimmedLine) || + /^## .*\b(Protocol|Receipt|Provenance|Registration)\b/i.test(trimmedLine) + ); +} + +/** + * Read all retained sections (Environment + operational protocol headings) from + * vendor file content and return them as a verbatim block to re-append after the + * thin pointer. Used by restorePointer to preserve section structure (#198). + */ +function readRetainedSections(content: string): string { + const lines = content.split('\n'); + const out: string[] = []; + let inRetained = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('## ')) { + inRetained = isRetainedHeading(trimmed); + if (inRetained) out.push(line); + continue; + } + if (inRetained) out.push(line); + } + + // Trim trailing blank lines + while (out.length > 0 && out[out.length - 1].trim() === '') out.pop(); + return out.length > 0 ? '\n' + out.join('\n') + '\n' : ''; +} + function extractBeyondPointer(content: string, fileName: string): string { const baseName = path.basename(fileName); const template = getPointerTemplates()[baseName]; @@ -312,11 +347,7 @@ function extractBeyondPointer(content: string, fileName: string): string { // Section detection if (trimmed.startsWith('## ')) { // Charter-managed and operational protocol sections retained in the vendor file (#198). - // Environment: WSL/OS runtime config. - // Module Index: charter-generated on-demand listing. - // Session Start / Session Protocol: operational wiring steps that belong in the - // vendor file as environment context, not in a domain ADF module. - const retained = /^## (Environment|Module Index|Session Start|Session Protocol|Session Setup)$/.test(trimmed); + const retained = isRetainedHeading(trimmed); if (retained) { inEnvironmentSection = true; continue; @@ -470,7 +501,10 @@ function restorePointer(filePath: string, stayItems: MigrationItem[]): void { '> See `.ai/manifest.adf` for the module routing manifest.\n'; } - // Re-attach retained environment items + // Read current file before overwriting — used to preserve retained sections. + const currentContent = fs.readFileSync(fullPath, 'utf-8'); + + // Re-attach any freshly-classified STAY items into ## Environment. const envItems = stayItems.filter(i => i.classification.reason.includes('Environment') || i.classification.reason.includes('runtime') || @@ -488,33 +522,17 @@ function restorePointer(filePath: string, stayItems: MigrationItem[]): void { } } - // Read current content to preserve any existing ## Environment items - // that were already there (not extracted as bloat) - const currentContent = fs.readFileSync(fullPath, 'utf-8'); - const currentLines = currentContent.split('\n'); - let inEnv = false; - const existingEnvLines: string[] = []; - for (const line of currentLines) { - if (line.trim() === '## Environment') { - inEnv = true; - continue; - } - if (line.startsWith('## ') && line.trim() !== '## Environment') { - inEnv = false; - } - if (inEnv && line.trim().startsWith('- ')) { - existingEnvLines.push(line); - } - } - - // If we didn't extract any stay items but there are existing env lines, preserve them - if (envItems.length === 0 && existingEnvLines.length > 0) { - const envSection = '\n## Environment\n' + existingEnvLines.join('\n') + '\n'; - if (pointer.includes('## Environment')) { - pointer = pointer.replace(/## Environment[\s\S]*$/, envSection.trim() + '\n'); - } else { - pointer += envSection; - } + // Re-append all retained sections (Environment, Session *, Protocol, etc.) from + // the current file verbatim so that operational protocol blocks survive the rewrite + // (#198). readRetainedSections() covers the same heading set as extractBeyondPointer. + const retainedBlock = readRetainedSections(currentContent); + if (retainedBlock) { + // Drop any existing ## Environment block already in `pointer` — it will be + // replaced by the verbatim block from the current file (which is fresher). + pointer = pointer.replace(/\n## Environment[\s\S]*$/, ''); + pointer = pointer.trimEnd() + retainedBlock; + } else if (envItems.length === 0) { + // Nothing retained and no stay items — pointer is clean, nothing to append. } fs.writeFileSync(fullPath, pointer); diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index a3f52c3..1d6b60a 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -11,6 +11,7 @@ import { formatAdf, applyPatches, CANONICAL_KEY_ORDER, + COMPILE_BANNER_MARKER, } from '@stackbilt/adf'; import type { AdfDocument, PatchOperation } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; @@ -250,6 +251,7 @@ interface AdfInitResult { /** Strings that identify an agent config file as a thin pointer to .ai/. */ export const POINTER_MARKERS = [ + COMPILE_BANNER_MARKER, 'Do not duplicate ADF rules here', 'Do not duplicate rules from .ai/', 'Do not add stack rules here', diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index c6b7c0c..8259ecb 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -13,6 +13,7 @@ import { parseAdf, parseManifest, stripCharterSentinels, evaluateLocBudgets, mat import type { LocBudgetRule } from '@stackbilt/adf'; import { isGitRepo } from '../git-helpers'; import { POINTER_MARKERS } from './adf'; +import { COMPILE_BANNER_MARKER } from '@stackbilt/adf'; interface DoctorResult { status: 'PASS' | 'WARN'; @@ -293,6 +294,9 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P } for (const { file, content } of pointerFiles) { + // Compile-output files contain ADF content by design — skip bloat scan. + if (content.includes(COMPILE_BANNER_MARKER)) continue; + // Strip charter-managed sentinel blocks before scanning for bloat/keywords. const strippedContent = stripCharterSentinels(content); const lines = strippedContent.split('\n'); diff --git a/packages/types/src/__tests__/tiers.test.ts b/packages/types/src/__tests__/tiers.test.ts new file mode 100644 index 0000000..945d9eb --- /dev/null +++ b/packages/types/src/__tests__/tiers.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for the tiered execution contract (#201). + * + * Verifies that each TierDefinition constant is structurally complete: + * - Every type value in the union appears in tiers[] + * - Every tier has a non-empty description and constraint description + * - mode is a valid value + * - tiers[] contains no duplicates + */ + +import { describe, it, expect } from 'vitest'; +import type { TierDefinition } from '../index'; +import { + APP_MODE_TIERS, + URGENCY_TIERS, + COMPLEXITY_TIERS, + CHANGE_CLASS_TIERS, + COMMIT_RISK_TIERS, +} from '../index'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function assertTierDefinitionComplete(def: TierDefinition): void { + expect(def.name.length).toBeGreaterThan(0); + expect(def.tiers.length).toBeGreaterThan(0); + expect(['additive', 'absolute']).toContain(def.mode); + + // No duplicate tiers + const unique = new Set(def.tiers); + expect(unique.size).toBe(def.tiers.length); + + // Every tier has a description and a constraint description + for (const tier of def.tiers) { + expect(def.descriptions[tier]).toBeTruthy(); + expect(def.constraints[tier]).toBeDefined(); + expect(def.constraints[tier].description.length).toBeGreaterThan(0); + } +} + +// --------------------------------------------------------------------------- +// Structural completeness +// --------------------------------------------------------------------------- + +describe('APP_MODE_TIERS', () => { + it('is structurally complete', () => assertTierDefinitionComplete(APP_MODE_TIERS)); + + it('contains all AppMode values', () => { + const values: string[] = ['GOVERNANCE', 'STRATEGY', 'DRAFTER', 'RED_TEAM', 'BRIEF']; + for (const v of values) { + expect(APP_MODE_TIERS.tiers).toContain(v); + } + }); + + it('is absolute mode', () => expect(APP_MODE_TIERS.mode).toBe('absolute')); +}); + +describe('URGENCY_TIERS', () => { + it('is structurally complete', () => assertTierDefinitionComplete(URGENCY_TIERS)); + + it('contains all Urgency values in ascending severity order', () => { + expect(URGENCY_TIERS.tiers).toEqual(['LOW', 'STANDARD', 'ELEVATED', 'CRITICAL']); + }); + + it('CRITICAL has the most restrictive constraint', () => { + expect(URGENCY_TIERS.constraints.CRITICAL.description).toContain('escalation'); + }); +}); + +describe('COMPLEXITY_TIERS', () => { + it('is structurally complete', () => assertTierDefinitionComplete(COMPLEXITY_TIERS)); + + it('contains all Complexity values in ascending severity order', () => { + expect(COMPLEXITY_TIERS.tiers).toEqual(['TRIVIAL', 'SIMPLE', 'MODERATE', 'COMPLEX', 'EPIC']); + }); + + it('EPIC requires architecture review', () => { + expect(COMPLEXITY_TIERS.constraints.EPIC.description).toContain('Architecture review'); + }); +}); + +describe('CHANGE_CLASS_TIERS', () => { + it('is structurally complete', () => assertTierDefinitionComplete(CHANGE_CLASS_TIERS)); + + it('contains all ChangeClass values', () => { + const values: string[] = ['SURFACE', 'LOCAL', 'CROSS_CUTTING']; + for (const v of values) { + expect(CHANGE_CLASS_TIERS.tiers).toContain(v); + } + }); + + it('CROSS_CUTTING requires committee review', () => { + expect(CHANGE_CLASS_TIERS.constraints.CROSS_CUTTING.description).toContain('Committee review'); + }); +}); + +describe('COMMIT_RISK_TIERS', () => { + it('is structurally complete', () => assertTierDefinitionComplete(COMMIT_RISK_TIERS)); + + it('contains all CommitRiskLevel values in ascending severity order', () => { + expect(COMMIT_RISK_TIERS.tiers).toEqual(['LOW', 'MEDIUM', 'HIGH']); + }); + + it('HIGH requires human review', () => { + expect(COMMIT_RISK_TIERS.constraints.HIGH.description).toContain('human review'); + }); + + it('LOW trailer is optional', () => { + expect(COMMIT_RISK_TIERS.constraints.LOW.description).toContain('optional'); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cc2a7e7..ec33761 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -344,6 +344,179 @@ export interface DriftReport { timestamp: string; } +// ============================================================================ +// Tiered Execution Contract (#201) +// +// Formalizes the tier-system pattern that appears independently across charter +// (CommitRiskLevel, ChangeClass, AppMode, Urgency, Complexity) and colonyos +// (cognitive-law tiers) and llm-providers (model-catalog tiers). Naming and +// contracting it here lets new tier systems be verified at type-check time. +// +// Invariants (TierSelector): +// 1. Deterministic — same input always yields the same tier +// 2. Caller-overridable — an explicit hint takes precedence over inference +// 3. Tier selection occurs before constraint application, never post-hoc +// 4. Transitions are observable (onTransition is emitted on tier change) +// 5. All declared tiers are reachable — no dead tiers in TierDefinition.tiers +// +// DegradationPolicy invariants (opt-in extension for health-signal-driven tiers): +// 1. Degradation is immediate — signal detected → tier applied same tick +// 2. Recovery is asymmetric — one step at a time, recoveryTicks stable ticks +// 3. Ceiling bounded externally — implementations must never self-grant a higher ceiling +// ============================================================================ + +/** What a tier constrains and the semantic description of that constraint. */ +export interface TierConstraint { + /** Natural language description of what governance behavior this tier constrains. */ + description: string; +} + +/** + * Declares a tier system: its named tiers in ascending severity order, + * semantic descriptions, per-tier constraints, and composition mode. + * + * @typeParam T - The union type of valid tier values (string literals). + */ +export interface TierDefinition { + /** Human-readable name of this tier system, e.g. 'CommitRiskLevel'. */ + name: string; + /** Ordered tier values, lowest severity first. All values must be reachable. */ + tiers: readonly T[]; + /** Semantic description for each tier. */ + descriptions: Readonly>; + /** What each tier constrains. */ + constraints: Readonly>; + /** 'absolute' — tier overrides; 'additive' — tiers stack cumulatively. */ + mode: 'additive' | 'absolute'; +} + +/** + * Selection contract for a tier system. + * Implementations must be deterministic and caller-overridable. + * + * @typeParam T - The union type of valid tier values. + * @typeParam Input - The input from which the tier is inferred. + */ +export interface TierSelector { + /** + * Select a tier from input. Deterministic: same input → same tier. + * When hint is provided it takes precedence over inference. + */ + select(input: Input, hint?: T): T; + /** Optional observer called when the active tier transitions. */ + onTransition?: (from: T, to: T, reason: string) => void; +} + +/** + * Extension of TierSelector for health-signal-driven degradation. + * Opt-in — only systems driven by signal counts need this. + * Reference implementation: colonyos cognitive-law.ts + */ +export interface DegradationPolicy extends TierSelector { + /** Degrade to the tier appropriate for signalCount. Immediate — same tick. */ + degrade(signalCount: number): T; + /** Consecutive stable ticks required to recover one tier. Asymmetric recovery. */ + readonly recoveryTicks: number; + /** Externally-configured ceiling — implementations must never self-grant above this. */ + readonly ceiling: T; + /** Floor — minimum tier regardless of signal count. */ + readonly floor: T; +} + +// ============================================================================ +// TierDefinition constants for charter's built-in tier systems +// ============================================================================ + +export const APP_MODE_TIERS: TierDefinition = { + name: 'AppMode', + tiers: ['BRIEF', 'DRAFTER', 'STRATEGY', 'RED_TEAM', 'GOVERNANCE'], + descriptions: { + BRIEF: 'Executive summary — condensed context, low verbosity', + DRAFTER: 'Content authoring — proposal and document generation', + STRATEGY: 'Strategic planning — roadmap and initiative framing', + RED_TEAM: 'Adversarial review — challenge assumptions and find gaps', + GOVERNANCE: 'Governance enforcement — policy check and compliance gate', + }, + constraints: { + BRIEF: { description: 'Minimal toolset; response length capped' }, + DRAFTER: { description: 'Drafting tools enabled; no enforcement gates' }, + STRATEGY: { description: 'Strategy tools enabled; advisory posture' }, + RED_TEAM: { description: 'Adversarial tools enabled; challenge posture' }, + GOVERNANCE: { description: 'Full policy toolset; enforcement gates active' }, + }, + mode: 'absolute', +}; + +export const URGENCY_TIERS: TierDefinition = { + name: 'Urgency', + tiers: ['LOW', 'STANDARD', 'ELEVATED', 'CRITICAL'], + descriptions: { + LOW: 'Routine — no SLA pressure; batch with next scheduled review', + STANDARD: 'Standard queue — normal review cadence applies', + ELEVATED: 'Elevated priority — review within one business day', + CRITICAL: 'Immediate escalation — blocks release or production safety', + }, + constraints: { + LOW: { description: 'Deferred to next review cycle' }, + STANDARD: { description: 'Normal prioritization queue' }, + ELEVATED: { description: 'Fast-tracked; skip standard queue' }, + CRITICAL: { description: 'Immediate human escalation required' }, + }, + mode: 'absolute', +}; + +export const COMPLEXITY_TIERS: TierDefinition = { + name: 'Complexity', + tiers: ['TRIVIAL', 'SIMPLE', 'MODERATE', 'COMPLEX', 'EPIC'], + descriptions: { + TRIVIAL: 'Typo, formatting, or comment change; no logic affected', + SIMPLE: 'Single-file change; bounded, self-contained logic', + MODERATE: 'Cross-file change within one package', + COMPLEX: 'Cross-package change; interface or contract modification', + EPIC: 'Cross-system or cross-repo change; architecture-level impact', + }, + constraints: { + TRIVIAL: { description: 'Self-review sufficient; no governance trailer needed' }, + SIMPLE: { description: 'Self-review sufficient; governance trailer recommended' }, + MODERATE: { description: 'Peer review required; governance trailer required' }, + COMPLEX: { description: 'Committee review required; ADR may be needed' }, + EPIC: { description: 'Architecture review required; ADR mandatory' }, + }, + mode: 'absolute', +}; + +export const CHANGE_CLASS_TIERS: TierDefinition = { + name: 'ChangeClass', + tiers: ['SURFACE', 'LOCAL', 'CROSS_CUTTING'], + descriptions: { + SURFACE: 'UI, style, docs, or test-only changes; no business logic affected', + LOCAL: 'Logic change scoped to a single package or subsystem', + CROSS_CUTTING: 'Change that crosses package or service boundaries', + }, + constraints: { + SURFACE: { description: 'No governance review gate; self-certify' }, + LOCAL: { description: 'Standard governance trailer required' }, + CROSS_CUTTING: { description: 'Committee review and ADR required' }, + }, + mode: 'absolute', +}; + +export const COMMIT_RISK_TIERS: TierDefinition = { + name: 'CommitRiskLevel', + tiers: ['LOW', 'MEDIUM', 'HIGH'], + descriptions: { + LOW: 'Docs, tests, tooling — no production logic changed', + MEDIUM: 'Feature or dependency change — standard governance applies', + HIGH: 'Auth, security, schema, or API surface change', + }, + constraints: { + LOW: { description: 'Governance trailer optional' }, + MEDIUM: { description: 'Governance trailer required (Governed-By or Resolves-Request)' }, + HIGH: { description: 'Governance trailer required; human review mandatory' }, + }, + mode: 'absolute', +}; + // ============================================================================ // Authority-Gated Governance Contract (#200) // diff --git a/packages/validate/src/__tests__/ontology.test.ts b/packages/validate/src/__tests__/ontology.test.ts index a05f593..a4e2c81 100644 --- a/packages/validate/src/__tests__/ontology.test.ts +++ b/packages/validate/src/__tests__/ontology.test.ts @@ -471,3 +471,32 @@ describe('checkOntologyDiff', () => { expect(violation!.sensitivity).toBe('billing_critical'); }); }); + +// ============================================================================ +// ONTOLOGY_SENSITIVITY_TIERS — TierDefinition completeness (#201) +// ============================================================================ + +import { ONTOLOGY_SENSITIVITY_TIERS } from '../ontology'; + +describe('ONTOLOGY_SENSITIVITY_TIERS', () => { + const ALL_TIERS = ['public', 'service_internal', 'cross_service_rpc', 'pii_scoped', 'billing_critical', 'secrets']; + + it('contains all OntologySensitivityTier values in ascending severity order', () => { + expect([...ONTOLOGY_SENSITIVITY_TIERS.tiers]).toEqual(ALL_TIERS); + }); + + it('has a description and constraint for every tier', () => { + for (const tier of ALL_TIERS) { + expect(ONTOLOGY_SENSITIVITY_TIERS.descriptions[tier as keyof typeof ONTOLOGY_SENSITIVITY_TIERS.descriptions]).toBeTruthy(); + expect(ONTOLOGY_SENSITIVITY_TIERS.constraints[tier as keyof typeof ONTOLOGY_SENSITIVITY_TIERS.constraints].description.length).toBeGreaterThan(0); + } + }); + + it('secrets tier prohibits all cross-boundary access', () => { + expect(ONTOLOGY_SENSITIVITY_TIERS.constraints.secrets.description).toContain('any condition'); + }); + + it('is absolute mode', () => { + expect(ONTOLOGY_SENSITIVITY_TIERS.mode).toBe('absolute'); + }); +}); diff --git a/packages/validate/src/index.ts b/packages/validate/src/index.ts index f07b000..185d230 100644 --- a/packages/validate/src/index.ts +++ b/packages/validate/src/index.ts @@ -28,4 +28,5 @@ export { type OntologyViolation, type OntologyReference, type OntologyCheckResult, + ONTOLOGY_SENSITIVITY_TIERS, } from './ontology'; diff --git a/packages/validate/src/ontology.ts b/packages/validate/src/ontology.ts index 99fbe13..92882b6 100644 --- a/packages/validate/src/ontology.ts +++ b/packages/validate/src/ontology.ts @@ -16,6 +16,8 @@ // Types // ============================================================================ +import type { TierDefinition } from '@stackbilt/types'; + export type OntologySensitivityTier = | 'public' | 'service_internal' @@ -24,6 +26,29 @@ export type OntologySensitivityTier = | 'billing_critical' | 'secrets'; +/** TierDefinition for the data-access sensitivity system (#201). */ +export const ONTOLOGY_SENSITIVITY_TIERS: TierDefinition = { + name: 'OntologySensitivityTier', + tiers: ['public', 'service_internal', 'cross_service_rpc', 'pii_scoped', 'billing_critical', 'secrets'], + descriptions: { + public: 'Readable from any service without auth (e.g. blog_post)', + service_internal: 'Readable/writable only by the owning service; raw D1 access is fine within the owner', + cross_service_rpc: 'Accessible only via declared RPC method or Service Binding — never raw D1 from a non-owner', + pii_scoped: 'Accessible only via owning service; audit_log entry required at every call site', + billing_critical: 'Writable only by the owning service + Stripe webhook handler; never leaves the service boundary', + secrets: 'Never leaves the owning service boundary under any circumstance', + }, + constraints: { + public: { description: 'No access restrictions' }, + service_internal: { description: 'Owner-service access only; raw D1 permitted within the owner' }, + cross_service_rpc: { description: 'RPC or Service Binding access only; raw D1 cross-service access is a DATA_AUTHORITY violation' }, + pii_scoped: { description: 'Owner-service access only; audit_log entry mandatory at every call site' }, + billing_critical: { description: 'Owner-service + Stripe webhook access only; RPC is also prohibited' }, + secrets: { description: 'In-process only; no cross-boundary access permitted under any condition' }, + }, + mode: 'absolute', +}; + export interface OntologyConcept { /** Canonical name, e.g. 'tenant', 'subscription', 'quota' */ name: string;