From d2d3434630e4022ff99243838c0557fde448e6fa Mon Sep 17 00:00:00 2001 From: Danh Doan Date: Sun, 10 May 2026 14:57:07 +0700 Subject: [PATCH 1/3] feat: [ENG-2738] HTML-emission contract in curate.txt + fluency check Rewrite the canonical curate tool description (loaded by ToolDescriptionLoader and presented to the agent on every curate operation) from the structured JSON-args shape (rawConcept/narrative/facts/etc.) to the M1 HTML-emission contract using the closed `` vocabulary defined by T1. Coverage: - All 5 elements (bv-topic, bv-rule, bv-decision, bv-bug, bv-fix) with full per-element attribute schemas: required/optional, value enums, format constraints (importance integer 0-100, recency numeric 0-1, ISO-8601 updatedat, severity enums per element, etc.). - Allowed-children semantics (any/inline/block) + pairing convention (bug+fix as siblings). - Path format (slash-separated snake_case) + domain guidelines. - Output contract: HTML only, exactly one bv-topic root, no preamble/fences/ commentary, lowercase attribute names per HTML5 spec, closed vocabulary, no invented attributes, no clarifying questions. - Three worked examples (bug+fix runbook, rule+decision pair, general topic) to anchor the model's element-pairing and id-naming conventions. Add a sanity test (test/unit/server/infra/render/curate-prompt.test.ts) that loads curate.txt and asserts every registered ELEMENT_NAME, every per-element enum value, and every output-contract directive is present. Guards against silent drift when M2 expands the vocabulary or refactors attribute schemas. Authoring fluency check (M1 T2 spike, separate harness in local-auto-test/curate-fluency/, not in this repo): 20/20 generated outputs validated against the M1 element registry on Sonnet 4.5. Gate cleared on first full run after one prompt-tuning iteration. Decision: proceed to T3. Full report: research repo at features/html-memory-conversion/milestones/01-experiment/fluency-report.md --- src/agent/resources/tools/curate.txt | 233 +++++++++++------- .../server/infra/render/curate-prompt.test.ts | 101 ++++++++ 2 files changed, 246 insertions(+), 88 deletions(-) create mode 100644 test/unit/server/infra/render/curate-prompt.test.ts diff --git a/src/agent/resources/tools/curate.txt b/src/agent/resources/tools/curate.txt index 0df959c5f..2130d605c 100644 --- a/src/agent/resources/tools/curate.txt +++ b/src/agent/resources/tools/curate.txt @@ -1,88 +1,145 @@ -Curate knowledge topics with atomic operations. This tool manages the knowledge structure using four operation types and supports a two-part context model: Raw Concept + Narrative. - -**Content Structure (Two-Part Model):** -- **tags**: Tags for categorization and filtering (e.g., ["authentication", "security", "jwt"]) -- **keywords**: Keywords for search and discovery (e.g., ["jwt", "refresh_token", "rotation"]) -- **rawConcept**: Captures essential metadata and context footprint - - task: What is the task or subject related to this concept - - changes: Array of changes or updates (code changes, process updates, decisions, etc.) - - files: Related resources: source files, documents, URLs, data sources, or references - - flow: Process flow, workflow, or sequence of steps - - timestamp: When created/modified (ISO 8601 format, e.g., 2025-03-18) -- **narrative**: Captures descriptive and structural context - - structure: Structural or organizational documentation (e.g., file layout, process hierarchy, timeline) - - dependencies: Dependencies, prerequisites, blockers, or relationship information - - highlights: Key highlights, capabilities, deliverables, or notable outcomes -- **facts**: Array of factual statements extracted from content - - statement: The full factual text (e.g., "My name is Andy", "We use PostgreSQL 15") - - category: Optional categorization - "personal", "project", "preference", "convention", "team", "environment", "other" - - subject: Optional subject key in snake_case (e.g., "user_name", "database", "sprint_duration") - - value: Optional extracted value (e.g., "Andy", "PostgreSQL 15", "2 weeks") -- **snippets**: Code/text snippets (legacy support, optional) -- **relations**: Related topics using @domain/topic notation - -**Per-Operation Metadata (required for all operations):** -- **reason**: WHY this knowledge is being curated — the motivation for a human reviewer -- **summary**: One-line semantic summary of what the knowledge file contains after this operation. Written for a human reviewer to quickly grasp the content. Example: "Caching strategy using Redis with 5-min TTL and write-through invalidation". Required for ADD/UPDATE/UPSERT/MERGE, not needed for DELETE. -- **confidence**: "high" or "low" — your confidence in accuracy/completeness -- **impact**: "high" or "low" — scope of change (see tool schema for details) - -**Operations:** -1. **ADD** - Create new titled context file in domain/topic/subtopic - - Requires: path, title, content, reason, summary - - Example with Raw Concept + Narrative: - { - type: "ADD", - path: "structure/caching", - title: "Redis User Permissions", - content: { - tags: ["caching", "redis", "performance"], - keywords: ["redis", "user_permissions", "cache_ttl", "singleton"], - rawConcept: { - task: "Introduce Redis cache for getUserPermissions(userId)", - changes: ["Cached result using remote Redis", "Redis client: singleton"], - files: ["services/permission_service.go", "clients/redis_client.go"], - flow: "getUserPermissions -> check Redis -> on miss query DB -> store result -> return", - timestamp: "2025-03-18" - }, - narrative: { - structure: "# Redis client\n- clients/redis_client.go", - dependencies: "# Redis client\n- Singleton, init when service starts", - highlights: "# Authorization\n- User permission can be stale for up to 300 seconds" - }, - relations: ["@structure/database"] - }, - reason: "New caching pattern", - summary: "Redis caching layer for getUserPermissions with 300s TTL and singleton client pattern", - confidence: "high", - impact: "low" - } - - Creates: structure/caching/redis_user_permissions.md - -2. **UPDATE** - Modify existing titled context file (full replacement) - - Requires: path, title, content, reason, summary - - Supports same content structure as ADD - -3. **MERGE** - Combine source file into target file, delete source - - Requires: path (source), title (source file), mergeTarget (destination path), mergeTargetTitle (destination file), reason - - Example: { type: "MERGE", path: "code_style/old_topic", title: "Old Guide", mergeTarget: "code_style/new_topic", mergeTargetTitle: "New Guide", reason: "Consolidating" } - - Raw concepts and narratives are intelligently merged - -4. **DELETE** - Remove specific file or entire folder - - Requires: path, title (optional), reason - - With title: deletes specific file; without title: deletes entire folder - -**Path format:** domain/topic or domain/topic/subtopic (uses snake_case automatically) -**File naming:** Titles are converted to snake_case (e.g., "Best Practices" -> "best_practices.md") - -**Domain creation guidelines:** -- Domains are created dynamically based on the content being curated -- Choose domain names that represent broad knowledge categories relevant to the content -- Domain names should be concise (1-3 words), use snake_case format -- Consolidate related concepts under the same domain for better organization -- Before creating a new domain, check if existing domains could accommodate the content -- Avoid generic names like `misc`, `other`, `general` - -**Backward Compatibility:** Existing context entries using only snippets and relations continue to work. - -**Output:** Returns applied operations with status (success/failed), filePath (for created/modified files), and a summary of counts. +Curate knowledge topics by emitting a single HTML topic document using the +closed `` vocabulary defined below. Each curate operation produces one +HTML document scoped to one topic file (identified by the `path` attribute on +``). File-level operations (create vs. update) are inferred from +whether the path already exists. + +**Output contract** + +- Output is HTML, and only HTML. +- The FIRST character of your response must be `<` (the opening of + ``). The LAST characters must be ``. +- DO NOT wrap the response in a code fence. No ` ``` `, no ` ```html `, + no markdown formatting around the HTML. Emit the HTML as a bare string. +- No prose preamble before ``. No commentary after ``. +- No HTML5 document preamble (no ``, no ``, ``, or + `` wrapper). +- Exactly one `` per output. It is the root container. +- All attribute names are lowercase (HTML5 normalizes attribute names at + parse time; emitting lowercase keeps source diffs clean). +- All attribute values are double-quoted strings. +- Do not invent custom elements outside the `` vocabulary. +- Do not invent attributes outside the per-element schema. +- Do not ask clarifying questions. Make a best-effort interpretation and + emit. + +**Path format** + +The `path` attribute on `` is a slash-separated topic path: +`/` or `//`. Use snake_case for each +segment (e.g., `security/auth`, `payments/refunds`, `infra/postgres_upgrade`). + +**Domain guidelines** + +- Choose domain names that represent broad knowledge categories relevant to + the content (1–3 words, snake_case). +- Consolidate related concepts under the same domain. +- Reuse existing domains where they fit; avoid generic names like `misc`, + `other`, `general`. + +**Element vocabulary (closed — do not extend)** + +`` — root container per topic file. + - allowed children: any (other `` elements and standard HTML) + - required attributes: + - `path` — slash-separated snake_case topic path (non-empty string) + - optional attributes: + - `importance` — integer string in `[0, 100]` (e.g., `"89"`) + - `maturity` — one of `"draft"`, `"validated"`, `"core"` + - `recency` — numeric string in `[0, 1]` (e.g., `"0.97"`) + - `updatedat` — ISO-8601 datetime (e.g., `"2026-04-27T08:17:42Z"`, + `"2026-04-27T08:17:42+02:00"`) + +`` — a rule statement the agent should follow. + - allowed children: inline content + - optional attributes: + - `severity` — one of `"info"`, `"should"`, `"must"` + - `id` — non-empty string for cross-referencing + +`` — a decision record (with rationale and evidence). + - allowed children: block content + - optional attributes: + - `id` — non-empty string for cross-referencing + +`` — a bug runbook entry (symptom, root cause). + - allowed children: block content + - optional attributes: + - `severity` — one of `"low"`, `"medium"`, `"high"`, `"critical"` + - `id` — non-empty string for cross-referencing + - typically paired with a sibling `` + +`` — a fix runbook entry (steps to resolve a bug). + - allowed children: block content + - optional attributes: + - `id` — non-empty string for cross-referencing + - typically follows a `` as a sibling (in that order) + +**Standard HTML inside `` elements** + +Inside any `` element you MAY use the following standard HTML for prose +structure: `h1`, `h2`, `h3`, `p`, `ul`, `ol`, `li`, `code`, `pre`, `strong`, +`em`. Do not introduce other custom elements. + +**Element pairing** + +When notes describe a bug and its fix, emit the pair as siblings inside +``: + +``` + + +

Symptom: ...

+
+ +

Steps: ...

+
+
+``` + +**Examples** + +A bug + fix runbook: + +``` + +

JWT refresh under clock skew

+ +

Clients with system clocks > 60s ahead of the auth service receive + 401 Unauthorized on token refresh.

+
+ +
    +
  1. Add a 90s leeway to the refresh validator.
  2. +
  3. Emit a metric on every refresh that exceeds the leeway.
  4. +
+
+
+``` + +A rule + decision pair: + +``` + + +

Use RS256 over HS256 for service-to-service tokens. Asymmetric keys + eliminate the need to share secrets across service boundaries.

+
+ Never log full JWTs at + any level. +
+``` + +A general project-context topic: + +``` + +

Postgres 14 → 16 upgrade plan

+

Two-phase upgrade: read replica first, then primary. Logical replication + used to bridge the version gap.

+
    +
  • Phase 1: spin up a Postgres-16 replica subscribed to the Postgres-14 + primary.
  • +
  • Phase 2: failover; promote the replica.
  • +
+
+``` diff --git a/test/unit/server/infra/render/curate-prompt.test.ts b/test/unit/server/infra/render/curate-prompt.test.ts new file mode 100644 index 000000000..5d4b5314b --- /dev/null +++ b/test/unit/server/infra/render/curate-prompt.test.ts @@ -0,0 +1,101 @@ +/** + * Sanity tests for the curate tool description prompt. + * + * The prompt at `src/agent/resources/tools/curate.txt` is the canonical + * curate output-format contract — it tells the agent that curate output + * is HTML using the M1 `` vocabulary. These tests guard against + * silent drift: if a future PR adds a new element to the registry but + * forgets the prompt, or removes a documented attribute without updating + * downstream consumers, this test fails loudly. + * + * The tests are deliberately string-level (not behavioural). The + * authoring-fluency check (M1 T2 spike) is the behavioural counterpart. + */ + +import {expect} from 'chai' +import {readFileSync} from 'node:fs' +import {join} from 'node:path' + +import {ELEMENT_NAMES} from '../../../../../src/server/core/domain/render/element-types.js' + +const PROMPT_PATH = join(process.cwd(), 'src/agent/resources/tools/curate.txt') + +function loadPrompt(): string { + return readFileSync(PROMPT_PATH, 'utf8') +} + +describe('curate.txt prompt', () => { + describe('vocabulary coverage', () => { + it('mentions every element name in the registry', () => { + const prompt = loadPrompt() + for (const name of ELEMENT_NAMES) { + expect(prompt, `expected prompt to mention <${name}>`).to.include(`<${name}>`) + } + }) + + it('flags `path` as the required attribute on bv-topic', () => { + // Required-vs-optional is the only attribute distinction the + // validator enforces today; if the prompt drops the requirement, + // generation drifts and bv-topic emits without `path`. + const prompt = loadPrompt() + expect(prompt).to.match(/required attributes:[\s\S]*?`path`/) + }) + + it('lists all bv-topic optional attributes (importance, maturity, recency, updatedat)', () => { + const prompt = loadPrompt() + for (const attr of ['importance', 'maturity', 'recency', 'updatedat']) { + expect(prompt, `expected prompt to mention bv-topic optional attribute "${attr}"`).to.include(`\`${attr}\``) + } + }) + + it('lists severity enum values for bv-rule (info|should|must)', () => { + const prompt = loadPrompt() + for (const value of ['info', 'should', 'must']) { + expect(prompt).to.include(`"${value}"`) + } + }) + + it('lists severity enum values for bv-bug (low|medium|high|critical)', () => { + const prompt = loadPrompt() + for (const value of ['low', 'medium', 'high', 'critical']) { + expect(prompt).to.include(`"${value}"`) + } + }) + + it('lists maturity enum values for bv-topic (draft|validated|core)', () => { + const prompt = loadPrompt() + for (const value of ['draft', 'validated', 'core']) { + expect(prompt).to.include(`"${value}"`) + } + }) + }) + + describe('output contract', () => { + it('declares the closed vocabulary', () => { + const prompt = loadPrompt() + expect(prompt.toLowerCase()).to.include('closed') + }) + + it('forbids prose preamble, code fences, and trailing commentary', () => { + const prompt = loadPrompt().toLowerCase() + expect(prompt).to.include('preamble') + expect(prompt).to.include('code fence') + expect(prompt).to.include('commentary') + }) + + it('requires exactly one bv-topic root', () => { + const prompt = loadPrompt() + expect(prompt.toLowerCase()).to.include('exactly one') + }) + + it('requires lowercase attribute names (HTML5 normalization)', () => { + const prompt = loadPrompt() + expect(prompt.toLowerCase()).to.include('lowercase') + }) + + it('forbids clarifying questions', () => { + const prompt = loadPrompt() + expect(prompt.toLowerCase()).to.include('clarifying question') + }) + }) +}) From 1be1dbd0c6ad85210d239e3307e57a2a87bc245c Mon Sep 17 00:00:00 2001 From: Danh Doan Date: Sun, 10 May 2026 15:41:32 +0700 Subject: [PATCH 2/3] feat: [ENG-2738] expand M1 vocabulary to match rendered MD structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-by-field review of curate.txt against the actual rendered .md context files (frontmatter + ## Reason + ## Raw Concept + ## Narrative + ## Facts) surfaced two issues with the initial T2 draft: 1. carried runtime-signal attributes (importance, maturity, recency, updatedat) that were explicitly migrated to a sidecar store under research/features/runtime-signals/ (per-user, per-machine state that caused noisy `vc status` diffs and team-share conflicts). Drop them from the schema; replace with the actual frontmatter fields the markdown writer renders: title (required), summary, tags, keywords, related. 2. The 5-element vocabulary covered Rules + three M1 net-new elements (Decision/Bug/Fix) but left every other rendered MD section as free-form HTML — losing the structural mapping the writer needs. Expand M1 vocabulary to 16 elements (added 11), each mapping to a specific section in the rendered .md output: - bv-reason → ## Reason - bv-task, → ## Raw Concept (Task / Changes / Files / Flow) bv-changes, bv-files, bv-flow - bv-structure, → ## Narrative (Structure / Dependencies / bv-dependencies, Highlights / Examples / Diagrams); bv-highlights, bv-rule already covered Rules bv-examples, bv-diagram (with type + title attrs for verbatim diagram preservation) - bv-fact (subject/category/value attrs) → ## Facts list Each element follows the existing makeAttributeValidator pattern; the data-driven registry stays the single source of truth. Tests: - Update bv-topic test to drop runtime-signal cases and assert the new required-attributes (path + title); add a regression case asserting passthrough still tolerates legacy importance/maturity/ recency without enforcing them. - Add registry test asserting (a) bv-topic exposes the new optionalAttributes, (b) runtime signals are NOT in the registry metadata, (c) every registered validator accepts its own empty node + rejects mismatched-tag. - Consolidate the 9 attribute-free new elements into one shared text-only-elements.test.ts (same trivial schema; per-element duplication wouldn't add coverage). - Add dedicated bv-diagram.test.ts (type enum) and bv-fact.test.ts (category enum, subject/value pass-through). - Update sample-topic.html fixture to exercise every new element + the new frontmatter attributes; assert no runtime signals leak in. - Update curate-prompt.test.ts to assert the expanded vocabulary, category/type enums for bv-fact/bv-diagram, and that the prompt explicitly excludes runtime signals from bv-topic attributes. Re-run the M1 T2 fluency check on the same 20 fixtures with the new vocabulary: 20 / 20 valid (gate cleared); 100% cohort-appropriate placement (bug+fix → bv-bug+bv-fix; rule+decision → bv-rule+bv-decision; general → bv-reason); 122 bv-fact instances extracted across the run (~6 per fixture). Fluency report updated: research/features/html-memory-conversion/milestones/01-experiment/fluency-report.md Open contract violation persists: 18/20 outputs (90%) wrap in code fences despite explicit prompt instruction. Recommendation for T3 unchanged — strip in the response parser, don't iterate the prompt. --- src/agent/resources/tools/curate.txt | 195 +++++++++++++----- .../core/domain/render/element-types.ts | 35 +++- .../render/elements/bv-changes/schema.ts | 10 + .../render/elements/bv-changes/validator.ts | 4 + .../render/elements/bv-dependencies/schema.ts | 10 + .../elements/bv-dependencies/validator.ts | 4 + .../render/elements/bv-diagram/schema.ts | 14 ++ .../render/elements/bv-diagram/validator.ts | 4 + .../render/elements/bv-examples/schema.ts | 9 + .../render/elements/bv-examples/validator.ts | 4 + .../infra/render/elements/bv-fact/schema.ts | 26 +++ .../render/elements/bv-fact/validator.ts | 4 + .../infra/render/elements/bv-files/schema.ts | 10 + .../render/elements/bv-files/validator.ts | 4 + .../infra/render/elements/bv-flow/schema.ts | 9 + .../render/elements/bv-flow/validator.ts | 4 + .../render/elements/bv-highlights/schema.ts | 10 + .../elements/bv-highlights/validator.ts | 4 + .../infra/render/elements/bv-reason/schema.ts | 10 + .../render/elements/bv-reason/validator.ts | 4 + .../render/elements/bv-structure/schema.ts | 10 + .../render/elements/bv-structure/validator.ts | 4 + .../infra/render/elements/bv-task/schema.ts | 9 + .../render/elements/bv-task/validator.ts | 4 + .../infra/render/elements/bv-topic/schema.ts | 41 ++-- src/server/infra/render/elements/registry.ts | 165 +++++++++++++-- test/fixtures/render/sample-topic.html | 53 +++-- .../server/infra/render/curate-prompt.test.ts | 66 +++++- .../infra/render/elements/bv-diagram.test.ts | 55 +++++ .../infra/render/elements/bv-fact.test.ts | 62 ++++++ .../infra/render/elements/bv-topic.test.ts | 136 ++++++------ .../infra/render/elements/registry.test.ts | 44 +++- .../elements/text-only-elements.test.ts | 65 ++++++ .../render/sample-topic-roundtrip.test.ts | 62 ++++-- 34 files changed, 930 insertions(+), 220 deletions(-) create mode 100644 src/server/infra/render/elements/bv-changes/schema.ts create mode 100644 src/server/infra/render/elements/bv-changes/validator.ts create mode 100644 src/server/infra/render/elements/bv-dependencies/schema.ts create mode 100644 src/server/infra/render/elements/bv-dependencies/validator.ts create mode 100644 src/server/infra/render/elements/bv-diagram/schema.ts create mode 100644 src/server/infra/render/elements/bv-diagram/validator.ts create mode 100644 src/server/infra/render/elements/bv-examples/schema.ts create mode 100644 src/server/infra/render/elements/bv-examples/validator.ts create mode 100644 src/server/infra/render/elements/bv-fact/schema.ts create mode 100644 src/server/infra/render/elements/bv-fact/validator.ts create mode 100644 src/server/infra/render/elements/bv-files/schema.ts create mode 100644 src/server/infra/render/elements/bv-files/validator.ts create mode 100644 src/server/infra/render/elements/bv-flow/schema.ts create mode 100644 src/server/infra/render/elements/bv-flow/validator.ts create mode 100644 src/server/infra/render/elements/bv-highlights/schema.ts create mode 100644 src/server/infra/render/elements/bv-highlights/validator.ts create mode 100644 src/server/infra/render/elements/bv-reason/schema.ts create mode 100644 src/server/infra/render/elements/bv-reason/validator.ts create mode 100644 src/server/infra/render/elements/bv-structure/schema.ts create mode 100644 src/server/infra/render/elements/bv-structure/validator.ts create mode 100644 src/server/infra/render/elements/bv-task/schema.ts create mode 100644 src/server/infra/render/elements/bv-task/validator.ts create mode 100644 test/unit/server/infra/render/elements/bv-diagram.test.ts create mode 100644 test/unit/server/infra/render/elements/bv-fact.test.ts create mode 100644 test/unit/server/infra/render/elements/text-only-elements.test.ts diff --git a/src/agent/resources/tools/curate.txt b/src/agent/resources/tools/curate.txt index 2130d605c..4600b40a9 100644 --- a/src/agent/resources/tools/curate.txt +++ b/src/agent/resources/tools/curate.txt @@ -1,8 +1,10 @@ Curate knowledge topics by emitting a single HTML topic document using the closed `` vocabulary defined below. Each curate operation produces one HTML document scoped to one topic file (identified by the `path` attribute on -``). File-level operations (create vs. update) are inferred from -whether the path already exists. +``). The rendered output preserves the same structure as the +existing markdown topic files: frontmatter (title, summary, tags, keywords, +related) on `` attributes; body sections (Reason, Raw Concept, +Narrative, Facts) on dedicated `` elements. **Output contract** @@ -37,56 +39,124 @@ segment (e.g., `security/auth`, `payments/refunds`, `infra/postgres_upgrade`). - Reuse existing domains where they fit; avoid generic names like `misc`, `other`, `general`. +**Frontmatter (attributes on ``)** + +- `path` — REQUIRED. Slash-separated snake_case topic path. +- `title` — REQUIRED. Human-readable short title for the topic. +- `summary` — RECOMMENDED. One-line semantic summary, written for a human + reviewer to grasp the content quickly. +- `tags` — optional. Comma-separated category tags (e.g., + `"security,authentication,jwt"`). +- `keywords` — optional. Comma-separated retrieval keywords (e.g., + `"jwt,refresh_token,rs256"`). +- `related` — optional. Comma-separated `@domain/topic` cross-references + (e.g., `"@security/cookies,@security/oauth"`). + +**NOT bv-topic attributes** — do not emit `importance`, `maturity`, +`recency`, `updatedat`, or `createdAt`. These are runtime signals tracked +by the system in a sidecar store; the LLM does not author them. + **Element vocabulary (closed — do not extend)** -`` — root container per topic file. - - allowed children: any (other `` elements and standard HTML) - - required attributes: - - `path` — slash-separated snake_case topic path (non-empty string) - - optional attributes: - - `importance` — integer string in `[0, 100]` (e.g., `"89"`) - - `maturity` — one of `"draft"`, `"validated"`, `"core"` - - `recency` — numeric string in `[0, 1]` (e.g., `"0.97"`) - - `updatedat` — ISO-8601 datetime (e.g., `"2026-04-27T08:17:42Z"`, - `"2026-04-27T08:17:42+02:00"`) - -`` — a rule statement the agent should follow. - - allowed children: inline content - - optional attributes: - - `severity` — one of `"info"`, `"should"`, `"must"` - - `id` — non-empty string for cross-referencing +`` — root container per topic file (see Frontmatter above). + +`` — RECOMMENDED. Renders as `## Reason`. The "why" of this + curate operation, stated for a human reviewer (one or two sentences). + +The following four elements form the `## Raw Concept` block (concept +metadata): + +`` — `## Raw Concept > Task:`. The subject or task this concept + relates to, in one sentence. + +`` — `## Raw Concept > Changes:`. A list of changes (code + changes, process updates, decisions). Use child `
  • ` items. + +`` — `## Raw Concept > Files:`. A list of related files, + documents, URLs, or references. Use child `
  • ` items. + +`` — `## Raw Concept > Flow:`. The process flow, workflow, or + step sequence (one paragraph or arrow-style). + +The following six elements form the `## Narrative` block (descriptive +context): + +`` — `## Narrative > Structure`. Structural or organizational + documentation (file layout, hierarchy, timeline). + +`` — `## Narrative > Dependencies`. Dependencies, + prerequisites, blockers, relationship information. + +`` — `## Narrative > Highlights`. Key highlights, + capabilities, deliverables, notable outcomes. + +`` — `## Narrative > Rules`. A rule the agent should follow. + - optional `severity` — one of `"info"`, `"should"`, `"must"`. + - optional `id` — non-empty string for cross-referencing. + Inline content. + +`` — `## Narrative > Examples`. Worked examples, sample + code, or scenario walkthroughs. + +`` — `## Narrative > Diagrams`. Preserves a diagram VERBATIM + (mermaid / plantuml / ascii / dot / graphviz). Use a `
    `
    +  block for the diagram body.
    +  - optional `type` — one of `"mermaid"`, `"plantuml"`, `"ascii"`,
    +    `"dot"`, `"graphviz"`, `"other"`.
    +  - optional `title` — caption for the diagram.
    +
    +`` — `## Facts`. A structured fact extracted from the input.
    +  The element's text content is the canonical statement; attributes
    +  carry the structured extraction.
    +  - optional `subject` — snake_case key (e.g., `"user_name"`,
    +    `"database_version"`).
    +  - optional `category` — one of `"personal"`, `"project"`,
    +    `"preference"`, `"convention"`, `"team"`, `"environment"`, `"other"`.
    +  - optional `value` — the extracted value (e.g., `"Andy"`,
    +    `"PostgreSQL 15"`).
     
     `` — a decision record (with rationale and evidence).
    -  - allowed children: block content
    -  - optional attributes:
    -    - `id` — non-empty string for cross-referencing
    +  - optional `id` — non-empty string for cross-referencing.
    +  Block content.
     
     `` — a bug runbook entry (symptom, root cause).
    -  - allowed children: block content
    -  - optional attributes:
    -    - `severity` — one of `"low"`, `"medium"`, `"high"`, `"critical"`
    -    - `id` — non-empty string for cross-referencing
    -  - typically paired with a sibling ``
    +  - optional `severity` — one of `"low"`, `"medium"`, `"high"`,
    +    `"critical"`.
    +  - optional `id` — non-empty string for cross-referencing.
    +  Block content. Typically paired with a sibling ``.
     
     `` — a fix runbook entry (steps to resolve a bug).
    -  - allowed children: block content
    -  - optional attributes:
    -    - `id` — non-empty string for cross-referencing
    -  - typically follows a `` as a sibling (in that order)
    +  - optional `id` — non-empty string for cross-referencing.
    +  Block content. Typically follows a `` as a sibling.
     
     **Standard HTML inside `` elements**
     
    -Inside any `` element you MAY use the following standard HTML for prose
    -structure: `h1`, `h2`, `h3`, `p`, `ul`, `ol`, `li`, `code`, `pre`, `strong`,
    +Inside any `` element you MAY use the following standard HTML for
    +structure: `h1`–`h6`, `p`, `ul`, `ol`, `li`, `code`, `pre`, `strong`,
     `em`. Do not introduce other custom elements.
     
    +**Detail-preservation**
    +
    +When the input contains diagrams, tables, code examples, factual
    +statements, or numbered procedures, preserve them faithfully:
    +
    +- Diagrams (mermaid, plantuml, ascii, dot) — preserve VERBATIM in
    +  ``. Never paraphrase.
    +- Tables — preserve every row and column.
    +- Step-by-step procedures — preserve original numbering in `
      ` + inside `` or ``. +- Code snippets — preserve exact syntax and values inside `
      `
      +  blocks (inside `` if illustrative).
      +- Factual statements — extract each as a separate `` with
      +  `subject` / `category` / `value` attributes filled where derivable.
      +
       **Element pairing**
       
       When notes describe a bug and its fix, emit the pair as siblings inside
       ``:
       
       ```
      -
      +
         
           

      Symptom: ...

      @@ -98,48 +168,63 @@ When notes describe a bug and its fix, emit the pair as siblings inside **Examples** -A bug + fix runbook: +A bug + fix runbook with full structure: ``` - -

      JWT refresh under clock skew

      + + Capture the clock-skew bug + leeway fix so the next on-call has the runbook. + Diagnose JWT refresh failures under client clock skew. + +
    1. Added 90s leeway to RefreshTokenValidator.
    2. +
    3. Emit auth.refresh.clock_skew_seconds metric on every refresh that exceeds the leeway.
    4. +
      + +
    5. src/auth/refresh-token-validator.ts
    6. +
      -

      Clients with system clocks > 60s ahead of the auth service receive - 401 Unauthorized on token refresh.

      +

      Symptom: clients with clocks > 60s ahead receive 401 on refresh.

      +

      Root cause: strict expiry check, no leeway.

        -
      1. Add a 90s leeway to the refresh validator.
      2. -
      3. Emit a metric on every refresh that exceeds the leeway.
      4. +
      5. Add 90s leeway to refresh validator.
      6. +
      7. Emit a clock-skew metric.
      + RefreshTokenValidator allows a 90-second leeway against client clock skew.
      ``` A rule + decision pair: ``` - + -

      Use RS256 over HS256 for service-to-service tokens. Asymmetric keys - eliminate the need to share secrets across service boundaries.

      +

      Use RS256 over HS256 for service-to-service tokens. Asymmetric keys eliminate the need to share secrets across service boundaries.

      - Never log full JWTs at - any level. + Never log full JWTs at any level. + Service tokens MUST expire within 1 hour. + Service-to-service JWTs use RS256.
      ``` -A general project-context topic: +A general project-context topic with a diagram: ``` - -

      Postgres 14 → 16 upgrade plan

      -

      Two-phase upgrade: read replica first, then primary. Logical replication - used to bridge the version gap.

      -
        -
      • Phase 1: spin up a Postgres-16 replica subscribed to the Postgres-14 - primary.
      • -
      • Phase 2: failover; promote the replica.
      • -
      + + Plan the 14 -> 16 upgrade with minimal downtime; document the rollback path. + +

      Two-phase upgrade: spin up a Postgres-16 replica using logical replication; failover during a 30-minute Sunday maintenance window.

      +
      + +

      pg_dump/pg_restore for initial seed; logical replication on the source. Some extensions (pg_stat_statements, postgis) need re-installation on the new instance.

      +
      + +
      [PG14 primary] --(logical replication)--> [PG16 replica]
      +                                                    |
      +                                          (cutover window)
      +                                                    v
      +                                          [PG16 primary]
      +
      ``` diff --git a/src/server/core/domain/render/element-types.ts b/src/server/core/domain/render/element-types.ts index aefd154c9..0a716bff4 100644 --- a/src/server/core/domain/render/element-types.ts +++ b/src/server/core/domain/render/element-types.ts @@ -14,12 +14,43 @@ */ /** - * The five M1 element names. Adding to this list must be an additive - * operation; downstream consumers iterate the registry generically. + * The M1 element names. The HTML curate format must round-trip through + * the markdown writer without information loss; the vocabulary covers + * everything the writer renders into the `.md` file: + * + * bv-topic — root container; carries frontmatter as attributes. + * bv-reason — `## Reason` body section. + * bv-task, — `## Raw Concept` sub-fields: + * bv-changes, Task / Changes / Files / Flow. + * bv-files, + * bv-flow + * bv-structure, — `## Narrative` sub-fields: + * bv-dependencies, Structure / Dependencies / Highlights / + * bv-highlights, Rules / Examples / Diagrams. + * bv-rule, + * bv-examples, + * bv-diagram + * bv-fact — `## Facts` list entry (subject/category/value attrs). + * bv-decision — net-new in M1: decision record. + * bv-bug, bv-fix — net-new in M1: paired bug + fix runbook entries. + * + * Adding to this list must be an additive operation; downstream + * consumers iterate the registry generically. */ export const ELEMENT_NAMES = [ 'bv-topic', + 'bv-reason', + 'bv-task', + 'bv-changes', + 'bv-files', + 'bv-flow', + 'bv-structure', + 'bv-dependencies', + 'bv-highlights', 'bv-rule', + 'bv-examples', + 'bv-diagram', + 'bv-fact', 'bv-decision', 'bv-bug', 'bv-fix', diff --git a/src/server/infra/render/elements/bv-changes/schema.ts b/src/server/infra/render/elements/bv-changes/schema.ts new file mode 100644 index 000000000..a72721894 --- /dev/null +++ b/src/server/infra/render/elements/bv-changes/schema.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as `**Changes:**` inside the `## Raw Concept` section — a + * list of changes (code, process, decision). Children should be `
    7. ` + * items; the writer flattens them into a markdown list. + */ +export const BvChangesAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-changes/validator.ts b/src/server/infra/render/elements/bv-changes/validator.ts new file mode 100644 index 000000000..a8d417873 --- /dev/null +++ b/src/server/infra/render/elements/bv-changes/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvChangesAttributesSchema} from './schema.js' + +export const validateBvChanges = makeAttributeValidator('bv-changes', BvChangesAttributesSchema) diff --git a/src/server/infra/render/elements/bv-dependencies/schema.ts b/src/server/infra/render/elements/bv-dependencies/schema.ts new file mode 100644 index 000000000..ec736d392 --- /dev/null +++ b/src/server/infra/render/elements/bv-dependencies/schema.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as the `### Dependencies` subsection inside `## Narrative` — + * dependencies, prerequisites, blockers, or relationship information. + * No attributes. + */ +export const BvDependenciesAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-dependencies/validator.ts b/src/server/infra/render/elements/bv-dependencies/validator.ts new file mode 100644 index 000000000..99e2fe138 --- /dev/null +++ b/src/server/infra/render/elements/bv-dependencies/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvDependenciesAttributesSchema} from './schema.js' + +export const validateBvDependencies = makeAttributeValidator('bv-dependencies', BvDependenciesAttributesSchema) diff --git a/src/server/infra/render/elements/bv-diagram/schema.ts b/src/server/infra/render/elements/bv-diagram/schema.ts new file mode 100644 index 000000000..8ee928a40 --- /dev/null +++ b/src/server/infra/render/elements/bv-diagram/schema.ts @@ -0,0 +1,14 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders verbatim into the `### Diagrams` subsection — preserves + * mermaid / plantuml / ascii / dot diagrams character-for-character + * (per the curate detail-preservation contract). The `type` attribute + * tells the writer which fenced-code-block language tag to emit. + */ +export const BvDiagramAttributesSchema = z.object({ + title: z.string().optional(), + type: z.enum(['mermaid', 'plantuml', 'ascii', 'dot', 'graphviz', 'other']).optional(), +}).passthrough() diff --git a/src/server/infra/render/elements/bv-diagram/validator.ts b/src/server/infra/render/elements/bv-diagram/validator.ts new file mode 100644 index 000000000..6b50dcc4e --- /dev/null +++ b/src/server/infra/render/elements/bv-diagram/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvDiagramAttributesSchema} from './schema.js' + +export const validateBvDiagram = makeAttributeValidator('bv-diagram', BvDiagramAttributesSchema) diff --git a/src/server/infra/render/elements/bv-examples/schema.ts b/src/server/infra/render/elements/bv-examples/schema.ts new file mode 100644 index 000000000..c4ccfed0c --- /dev/null +++ b/src/server/infra/render/elements/bv-examples/schema.ts @@ -0,0 +1,9 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as the `### Examples` subsection inside `## Narrative` — + * worked examples, sample code, or scenario walkthroughs. No attributes. + */ +export const BvExamplesAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-examples/validator.ts b/src/server/infra/render/elements/bv-examples/validator.ts new file mode 100644 index 000000000..bb11771a8 --- /dev/null +++ b/src/server/infra/render/elements/bv-examples/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvExamplesAttributesSchema} from './schema.js' + +export const validateBvExamples = makeAttributeValidator('bv-examples', BvExamplesAttributesSchema) diff --git a/src/server/infra/render/elements/bv-fact/schema.ts b/src/server/infra/render/elements/bv-fact/schema.ts new file mode 100644 index 000000000..4f13129a3 --- /dev/null +++ b/src/server/infra/render/elements/bv-fact/schema.ts @@ -0,0 +1,26 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as a `## Facts` list entry. Mirrors the existing structured-fact + * model (statement / category / subject / value): + * + * My name is Andy. + * + * The element's text content is the canonical statement; attributes are + * the structured extraction. + */ +export const BvFactAttributesSchema = z.object({ + category: z.enum([ + 'personal', + 'project', + 'preference', + 'convention', + 'team', + 'environment', + 'other', + ]).optional(), + subject: z.string().optional(), + value: z.string().optional(), +}).passthrough() diff --git a/src/server/infra/render/elements/bv-fact/validator.ts b/src/server/infra/render/elements/bv-fact/validator.ts new file mode 100644 index 000000000..5e65c5f45 --- /dev/null +++ b/src/server/infra/render/elements/bv-fact/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvFactAttributesSchema} from './schema.js' + +export const validateBvFact = makeAttributeValidator('bv-fact', BvFactAttributesSchema) diff --git a/src/server/infra/render/elements/bv-files/schema.ts b/src/server/infra/render/elements/bv-files/schema.ts new file mode 100644 index 000000000..ef610df46 --- /dev/null +++ b/src/server/infra/render/elements/bv-files/schema.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as `**Files:**` inside the `## Raw Concept` section — a list + * of related source files, documents, URLs, or references. Children + * should be `
    8. ` items. + */ +export const BvFilesAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-files/validator.ts b/src/server/infra/render/elements/bv-files/validator.ts new file mode 100644 index 000000000..f7fb99659 --- /dev/null +++ b/src/server/infra/render/elements/bv-files/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvFilesAttributesSchema} from './schema.js' + +export const validateBvFiles = makeAttributeValidator('bv-files', BvFilesAttributesSchema) diff --git a/src/server/infra/render/elements/bv-flow/schema.ts b/src/server/infra/render/elements/bv-flow/schema.ts new file mode 100644 index 000000000..c702e4221 --- /dev/null +++ b/src/server/infra/render/elements/bv-flow/schema.ts @@ -0,0 +1,9 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as `**Flow:**` inside the `## Raw Concept` section — the + * process flow, workflow, or sequence of steps. No attributes. + */ +export const BvFlowAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-flow/validator.ts b/src/server/infra/render/elements/bv-flow/validator.ts new file mode 100644 index 000000000..dca56de59 --- /dev/null +++ b/src/server/infra/render/elements/bv-flow/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvFlowAttributesSchema} from './schema.js' + +export const validateBvFlow = makeAttributeValidator('bv-flow', BvFlowAttributesSchema) diff --git a/src/server/infra/render/elements/bv-highlights/schema.ts b/src/server/infra/render/elements/bv-highlights/schema.ts new file mode 100644 index 000000000..95ab1e44e --- /dev/null +++ b/src/server/infra/render/elements/bv-highlights/schema.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as the `### Highlights` subsection inside `## Narrative` — + * key highlights, capabilities, deliverables, or notable outcomes. + * No attributes. + */ +export const BvHighlightsAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-highlights/validator.ts b/src/server/infra/render/elements/bv-highlights/validator.ts new file mode 100644 index 000000000..735342f27 --- /dev/null +++ b/src/server/infra/render/elements/bv-highlights/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvHighlightsAttributesSchema} from './schema.js' + +export const validateBvHighlights = makeAttributeValidator('bv-highlights', BvHighlightsAttributesSchema) diff --git a/src/server/infra/render/elements/bv-reason/schema.ts b/src/server/infra/render/elements/bv-reason/schema.ts new file mode 100644 index 000000000..898f37907 --- /dev/null +++ b/src/server/infra/render/elements/bv-reason/schema.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as the `## Reason` body section in the .md writer — the + * curate operation's "why" stated for a human reviewer. Has no + * attributes; the body text is the rendered content. + */ +export const BvReasonAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-reason/validator.ts b/src/server/infra/render/elements/bv-reason/validator.ts new file mode 100644 index 000000000..30f5af9b2 --- /dev/null +++ b/src/server/infra/render/elements/bv-reason/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvReasonAttributesSchema} from './schema.js' + +export const validateBvReason = makeAttributeValidator('bv-reason', BvReasonAttributesSchema) diff --git a/src/server/infra/render/elements/bv-structure/schema.ts b/src/server/infra/render/elements/bv-structure/schema.ts new file mode 100644 index 000000000..1f5adc5bc --- /dev/null +++ b/src/server/infra/render/elements/bv-structure/schema.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as the `### Structure` subsection inside `## Narrative` — + * structural or organizational documentation (file layout, process + * hierarchy, timeline). No attributes. + */ +export const BvStructureAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-structure/validator.ts b/src/server/infra/render/elements/bv-structure/validator.ts new file mode 100644 index 000000000..0e7b0acd4 --- /dev/null +++ b/src/server/infra/render/elements/bv-structure/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvStructureAttributesSchema} from './schema.js' + +export const validateBvStructure = makeAttributeValidator('bv-structure', BvStructureAttributesSchema) diff --git a/src/server/infra/render/elements/bv-task/schema.ts b/src/server/infra/render/elements/bv-task/schema.ts new file mode 100644 index 000000000..fefc97392 --- /dev/null +++ b/src/server/infra/render/elements/bv-task/schema.ts @@ -0,0 +1,9 @@ +import {z} from 'zod' + +/** + * Zod schema for `` attributes. + * + * Renders as `**Task:**` inside the `## Raw Concept` section — the + * subject/task this concept relates to. No attributes. + */ +export const BvTaskAttributesSchema = z.object({}).passthrough() diff --git a/src/server/infra/render/elements/bv-task/validator.ts b/src/server/infra/render/elements/bv-task/validator.ts new file mode 100644 index 000000000..f3f35d97b --- /dev/null +++ b/src/server/infra/render/elements/bv-task/validator.ts @@ -0,0 +1,4 @@ +import {makeAttributeValidator} from '../make-validator.js' +import {BvTaskAttributesSchema} from './schema.js' + +export const validateBvTask = makeAttributeValidator('bv-task', BvTaskAttributesSchema) diff --git a/src/server/infra/render/elements/bv-topic/schema.ts b/src/server/infra/render/elements/bv-topic/schema.ts index 7a605d307..bad3275d8 100644 --- a/src/server/infra/render/elements/bv-topic/schema.ts +++ b/src/server/infra/render/elements/bv-topic/schema.ts @@ -3,36 +3,27 @@ import {z} from 'zod' /** * Zod schema for `` attributes. * - * HTML attributes arrive as strings. Numeric and enum constraints are - * expressed via regex + refine, since HTML never gives us native numbers. + * `` carries the topic file's frontmatter as attributes. The + * markdown writer maps these directly to YAML frontmatter on disk. + * + * Notably absent: `importance`, `maturity`, `recency`, `updatedat`, + * `createdAt`. Per the runtime-signals migration (research/features/ + * runtime-signals/), ranking signals are *sidecar* state — per-user, + * per-machine — not file content. Including them as attributes here + * would re-introduce the noise-from-implicit-state problem the + * migration solved. The system writes timestamps; the LLM does not. * * `passthrough` is intentional: M1 is permissive on unknown attributes * (parse-and-skip — no warning is emitted). Strict validation per * ADR-007 §13 is M2 work. */ export const BvTopicAttributesSchema = z.object({ - importance: z - .string() - .regex(/^\d+$/, {message: 'importance must be an integer string "0".."100"'}) - .refine((v) => { - const n = Number(v) - return n >= 0 && n <= 100 - }, {message: 'importance must be in [0, 100]'}) - .optional(), - maturity: z.enum(['draft', 'validated', 'core']).optional(), + // Comma-separated lists are the natural HTML-attribute encoding for + // arrays. The writer splits on `,` and trims; empty list is `""`. + keywords: z.string().optional(), path: z.string().min(1, {message: 'path is required and must be non-empty'}), - recency: z - .string() - .regex(/^\d+(\.\d+)?$/, {message: 'recency must be a numeric string'}) - .refine((v) => { - const n = Number(v) - return n >= 0 && n <= 1 - }, {message: 'recency must be in [0, 1]'}) - .optional(), - // Lowercase per HTML5 attribute-name normalization (parse5 lowercases - // `updatedAt="..."` to `updatedat`; schema keys must match the parser - // output, not the source HTML). See element-types.ts attribute-case note. - // `offset: true` accepts explicit timezone offsets like `+02:00` (e.g. - // from `git log --date=iso-strict`), not only `Z`. - updatedat: z.string().datetime({message: 'updatedat must be ISO-8601 datetime', offset: true}).optional(), + related: z.string().optional(), + summary: z.string().optional(), + tags: z.string().optional(), + title: z.string().min(1, {message: 'title is required and must be non-empty'}), }).passthrough() diff --git a/src/server/infra/render/elements/registry.ts b/src/server/infra/render/elements/registry.ts index 5dc4ab88b..ec615cc53 100644 --- a/src/server/infra/render/elements/registry.ts +++ b/src/server/infra/render/elements/registry.ts @@ -1,34 +1,60 @@ import type {ElementRegistry} from '../../../core/domain/render/element-types.js' import {validateBvBug} from './bv-bug/validator.js' +import {validateBvChanges} from './bv-changes/validator.js' import {validateBvDecision} from './bv-decision/validator.js' +import {validateBvDependencies} from './bv-dependencies/validator.js' +import {validateBvDiagram} from './bv-diagram/validator.js' +import {validateBvExamples} from './bv-examples/validator.js' +import {validateBvFact} from './bv-fact/validator.js' +import {validateBvFiles} from './bv-files/validator.js' import {validateBvFix} from './bv-fix/validator.js' +import {validateBvFlow} from './bv-flow/validator.js' +import {validateBvHighlights} from './bv-highlights/validator.js' +import {validateBvReason} from './bv-reason/validator.js' import {validateBvRule} from './bv-rule/validator.js' +import {validateBvStructure} from './bv-structure/validator.js' +import {validateBvTask} from './bv-task/validator.js' import {validateBvTopic} from './bv-topic/validator.js' /** - * The M1 element registry — single source of truth for the 5-element - * vocabulary. M2 vocabulary expansion (12 more elements per Andy's - * proposal §11) is **purely additive**: add a new entry here and a new - * `/{schema,validator}.ts` pair under `elements/`. No consumer - * (writer, reader, indexer, prompt template generator) needs to be - * touched — they all walk this registry generically. + * The M1 element registry — single source of truth for the M1 vocabulary. + * The vocabulary covers every section of the rendered .md file (frontmatter + * + Reason + Raw Concept + Narrative + Facts) plus three M1 net-new elements + * (decision, bug, fix). M2 vocabulary expansion is **purely additive**: add + * an entry here and a `/{schema,validator}.ts` pair under `elements/`. + * No consumer (writer, reader, indexer, prompt template generator) needs + * to be touched — they all walk this registry generically. * * The data-driven shape is the production-track guardrail. If you find * yourself writing `switch (elementName)` anywhere in the render layer, - * push back: that pattern doesn't scale to M2's vocabulary expansion. + * push back: that pattern doesn't scale to vocabulary expansion. + * + * Notably absent: `importance`, `maturity`, `recency`, `updatedat`, + * `createdAt`. Per the runtime-signals migration, ranking signals live + * in the sidecar store keyed by relpath — not in topic file content. */ export const ELEMENT_REGISTRY: ElementRegistry = { 'bv-bug': { allowedChildren: 'block', description: - 'A bug runbook entry (symptom, root cause, fix). Optional `id` and `severity` ' + + 'A bug runbook entry (symptom, root cause). Optional `id` and `severity` ' + '(low|medium|high|critical). Typically paired with a sibling ``.', name: 'bv-bug', optionalAttributes: ['id', 'severity'], requiredAttributes: [], validator: validateBvBug, }, + 'bv-changes': { + allowedChildren: 'block', + description: + 'Renders as `**Changes:**` inside the `## Raw Concept` section. ' + + 'Children should be `
    9. ` items.', + name: 'bv-changes', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvChanges, + }, 'bv-decision': { allowedChildren: 'block', description: @@ -39,35 +65,140 @@ export const ELEMENT_REGISTRY: ElementRegistry = { requiredAttributes: [], validator: validateBvDecision, }, + 'bv-dependencies': { + allowedChildren: 'block', + description: + 'Renders as the `### Dependencies` subsection inside `## Narrative` — ' + + 'dependencies, prerequisites, blockers.', + name: 'bv-dependencies', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvDependencies, + }, + 'bv-diagram': { + allowedChildren: 'block', + description: + 'Preserves a diagram (mermaid / plantuml / ascii / dot) verbatim. ' + + 'Optional `type` selects the fenced-code-block language tag; optional ' + + '`title` becomes the diagram caption.', + name: 'bv-diagram', + optionalAttributes: ['type', 'title'], + requiredAttributes: [], + validator: validateBvDiagram, + }, + 'bv-examples': { + allowedChildren: 'block', + description: + 'Renders as the `### Examples` subsection inside `## Narrative` — ' + + 'worked examples, sample code, scenario walkthroughs.', + name: 'bv-examples', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvExamples, + }, + 'bv-fact': { + allowedChildren: 'inline', + description: + 'A structured fact rendered into the `## Facts` list. Text content is ' + + 'the canonical statement; optional attributes carry the structured ' + + 'extraction (subject, category in {personal|project|preference|' + + 'convention|team|environment|other}, value).', + name: 'bv-fact', + optionalAttributes: ['subject', 'category', 'value'], + requiredAttributes: [], + validator: validateBvFact, + }, + 'bv-files': { + allowedChildren: 'block', + description: + 'Renders as `**Files:**` inside the `## Raw Concept` section. ' + + 'Children should be `
    10. ` items.', + name: 'bv-files', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvFiles, + }, 'bv-fix': { allowedChildren: 'block', description: - 'A fix runbook entry (steps to resolve a bug). Optional `id`. Typically the ' + - 'sibling of a ``.', + 'A fix runbook entry (steps to resolve a bug). Optional `id`. Typically ' + + 'the sibling of a ``.', name: 'bv-fix', optionalAttributes: ['id'], requiredAttributes: [], validator: validateBvFix, }, + 'bv-flow': { + allowedChildren: 'inline', + description: + 'Renders as `**Flow:**` inside the `## Raw Concept` section — ' + + 'process flow, workflow, or sequence of steps.', + name: 'bv-flow', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvFlow, + }, + 'bv-highlights': { + allowedChildren: 'block', + description: + 'Renders as the `### Highlights` subsection inside `## Narrative` — ' + + 'key highlights, capabilities, deliverables, notable outcomes.', + name: 'bv-highlights', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvHighlights, + }, + 'bv-reason': { + allowedChildren: 'block', + description: + 'Renders as the `## Reason` body section — the curate operation\'s ' + + '"why" stated for a human reviewer.', + name: 'bv-reason', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvReason, + }, 'bv-rule': { allowedChildren: 'inline', description: - 'A rule statement the agent should follow. Optional `severity` (info|must|should) ' + - 'and `id` for cross-referencing.', + 'A rule statement the agent should follow. Optional `severity` ' + + '(info|must|should) and `id` for cross-referencing.', name: 'bv-rule', optionalAttributes: ['severity', 'id'], requiredAttributes: [], validator: validateBvRule, }, + 'bv-structure': { + allowedChildren: 'block', + description: + 'Renders as the `### Structure` subsection inside `## Narrative` — ' + + 'structural or organizational documentation (file layout, hierarchy).', + name: 'bv-structure', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvStructure, + }, + 'bv-task': { + allowedChildren: 'inline', + description: + 'Renders as `**Task:**` inside the `## Raw Concept` section — the ' + + 'task or subject this concept relates to.', + name: 'bv-task', + optionalAttributes: [], + requiredAttributes: [], + validator: validateBvTask, + }, 'bv-topic': { allowedChildren: 'any', description: - 'Root container per topic file. Carries file-level metadata as attributes ' + - '(importance, maturity, recency, updatedat). Required: `path`. Note: ' + - 'attribute names MUST be lowercase — HTML5 normalizes them at parse time.', + 'Root container per topic file. Carries frontmatter as attributes ' + + '(title, summary, tags, keywords, related, path). Required: `path`, ' + + '`title`. Note: attribute names MUST be lowercase — HTML5 normalizes ' + + 'them at parse time. Runtime signals (importance/maturity/recency) ' + + 'are sidecar state and are NOT carried as attributes.', name: 'bv-topic', - optionalAttributes: ['importance', 'maturity', 'recency', 'updatedat'], - requiredAttributes: ['path'], + optionalAttributes: ['summary', 'tags', 'keywords', 'related'], + requiredAttributes: ['path', 'title'], validator: validateBvTopic, }, } diff --git a/test/fixtures/render/sample-topic.html b/test/fixtures/render/sample-topic.html index 90346f4ac..054d366c0 100644 --- a/test/fixtures/render/sample-topic.html +++ b/test/fixtures/render/sample-topic.html @@ -1,15 +1,42 @@ - -

      Authentication and Authorization

      + + Capture the auth subsystem's standing rules and the decisions that shaped them, plus the runbook for the recent revocation-cache leak so the next on-call has it. -

      Project-wide rules and decisions for the auth subsystem. JWTs are signed with RS256; refresh tokens are sliding-expiry with a 24-hour window.

      + Document JWT-based authentication for service-to-service and user flows. + +
    11. Adopted RS256 (asymmetric) over HS256 for service-to-service tokens.
    12. +
    13. Refresh tokens use sliding 24h expiry; rotation on every refresh.
    14. +
    15. Logout now evicts the refresh-token entry from the revocation cache synchronously.
    16. +
      + +
    17. src/auth/jwt.ts
    18. +
    19. src/auth/logout-handler.ts
    20. +
    21. test/integration/auth/logout-revocation.test.ts
    22. +
      + request → verify access token → on 401 client calls /auth/refresh → server checks revocation cache → rotates both tokens → responds with new pair in httpOnly cookies. - - Failed token validation MUST return 401 Unauthorized — never 403, never 500. - + JWT auth issues access/refresh token pairs. Access tokens expire in 15min, refresh tokens in 24h. Tokens are stored as httpOnly Secure SameSite=Strict cookies. Refresh rotation evicts the prior token from the revocation cache. + jsonwebtoken for signing/verification; the revocation cache (Redis) for token denylist; httpOnly cookie support in the application framework. + RS256 signing (no shared secrets), 30-day key rotation via JWKS, synchronous logout-revocation eviction, integration test covering the post-logout 401 path. - - The RS256 signing key SHOULD rotate every 30 days. Old keys remain in the JWKS until tokens signed with them expire. - + Failed token validation MUST return 401 Unauthorized — never 403, never 500. + The RS256 signing key SHOULD rotate every 30 days. Old keys remain in the JWKS until tokens signed with them expire. + Document access-token expiry on every public API endpoint. + + +

      Example: a user with a stale access token calls a protected endpoint, receives 401, and the client transparently calls /auth/refresh with the refresh token from its httpOnly cookie. The server rotates both tokens and the client retries the original request.

      +
      + + +
      sequenceDiagram
      +  Client->>API: GET /resource (expired access)
      +  API-->>Client: 401
      +  Client->>Auth: POST /auth/refresh
      +  Auth->>RevocationCache: check
      +  RevocationCache-->>Auth: ok
      +  Auth-->>Client: new {access, refresh}
      +  Client->>API: GET /resource (new access)
      +  API-->>Client: 200
      +

      Use RS256 (asymmetric), not HS256 (shared-secret).

      @@ -22,14 +49,14 @@

      Authentication and Authorization

      -

      On logout, evict the user's refresh-token entry from the revocation cache synchronously before responding to the client. The cache is now read-through with a 30-second TTL; revocations bypass it entirely.

      +

      On logout, evict the user's refresh-token entry from the revocation cache synchronously before responding to the client.

      • Updated logout-handler.ts:42 to call revocationCache.invalidate(userId) before response.send().
      • Added integration test logout-revocation.test.ts covering the post-logout 401 path.
      - - Document access-token expiry on every public API endpoint. - + Service-to-service JWTs are signed with RS256. + Access tokens expire in 15 minutes. + Refresh tokens use sliding 24-hour expiry with rotation on use.
      diff --git a/test/unit/server/infra/render/curate-prompt.test.ts b/test/unit/server/infra/render/curate-prompt.test.ts index 5d4b5314b..334397138 100644 --- a/test/unit/server/infra/render/curate-prompt.test.ts +++ b/test/unit/server/infra/render/curate-prompt.test.ts @@ -17,6 +17,7 @@ import {readFileSync} from 'node:fs' import {join} from 'node:path' import {ELEMENT_NAMES} from '../../../../../src/server/core/domain/render/element-types.js' +import {ELEMENT_REGISTRY} from '../../../../../src/server/infra/render/elements/registry.js' const PROMPT_PATH = join(process.cwd(), 'src/agent/resources/tools/curate.txt') @@ -33,21 +34,29 @@ describe('curate.txt prompt', () => { } }) - it('flags `path` as the required attribute on bv-topic', () => { - // Required-vs-optional is the only attribute distinction the - // validator enforces today; if the prompt drops the requirement, - // generation drifts and bv-topic emits without `path`. + it('flags `path` and `title` as required attributes on bv-topic', () => { const prompt = loadPrompt() - expect(prompt).to.match(/required attributes:[\s\S]*?`path`/) + // Both are REQUIRED on bv-topic per the schema; the prompt must say so. + expect(prompt).to.match(/`path`[^\n]*REQUIRED/i) + expect(prompt).to.match(/`title`[^\n]*REQUIRED/i) }) - it('lists all bv-topic optional attributes (importance, maturity, recency, updatedat)', () => { + it('lists bv-topic frontmatter optional attributes (summary, tags, keywords, related)', () => { const prompt = loadPrompt() - for (const attr of ['importance', 'maturity', 'recency', 'updatedat']) { - expect(prompt, `expected prompt to mention bv-topic optional attribute "${attr}"`).to.include(`\`${attr}\``) + for (const attr of ['summary', 'tags', 'keywords', 'related']) { + expect(prompt, `expected prompt to mention bv-topic frontmatter attribute "${attr}"`).to.include(`\`${attr}\``) } }) + it('explicitly excludes runtime signals from bv-topic attributes', () => { + const prompt = loadPrompt().toLowerCase() + // The prompt must instruct the LLM NOT to author runtime-signal + // attributes — those live in the sidecar store. If this assertion + // disappears, the LLM may start emitting noisy importance/recency + // attributes again. + expect(prompt).to.match(/not.*bv-topic.*importance|importance[\s\S]*sidecar|do not.*importance/) + }) + it('lists severity enum values for bv-rule (info|should|must)', () => { const prompt = loadPrompt() for (const value of ['info', 'should', 'must']) { @@ -62,10 +71,34 @@ describe('curate.txt prompt', () => { } }) - it('lists maturity enum values for bv-topic (draft|validated|core)', () => { + it('lists category enum values for bv-fact', () => { const prompt = loadPrompt() - for (const value of ['draft', 'validated', 'core']) { - expect(prompt).to.include(`"${value}"`) + for (const value of ['personal', 'project', 'preference', 'convention', 'team', 'environment', 'other']) { + expect(prompt, `expected category value "${value}" in prompt`).to.include(`"${value}"`) + } + }) + + it('lists type enum values for bv-diagram', () => { + const prompt = loadPrompt() + for (const value of ['mermaid', 'plantuml', 'ascii', 'dot', 'graphviz']) { + expect(prompt, `expected diagram type "${value}" in prompt`).to.include(`"${value}"`) + } + }) + + it('declares each registered element somewhere with an explanatory blurb', () => { + // Stronger drift guard than just-mention: every element must have + // at least one mention adjacent to either an attribute reference + // or a "renders as" / "## section" / "block content" / "inline" + // signal — i.e., the prompt actually describes the element rather + // than just naming it in passing. + const prompt = loadPrompt() + for (const name of ELEMENT_NAMES) { + if (name === 'bv-topic') continue + const idx = prompt.indexOf(`<${name}>`) + expect(idx, `expected <${name}> mentioned`).to.be.greaterThan(-1) + const window = prompt.slice(idx, idx + 600) + const hasContext = /renders as|`##|block content|inline|optional|REQUIRED|attribute/i.test(window) + expect(hasContext, `expected explanatory context near <${name}>`).to.equal(true) } }) }) @@ -98,4 +131,15 @@ describe('curate.txt prompt', () => { expect(prompt.toLowerCase()).to.include('clarifying question') }) }) + + describe('field coverage matches registry', () => { + it('mentions every required attribute declared in the registry for every element', () => { + const prompt = loadPrompt() + for (const name of ELEMENT_NAMES) { + for (const attr of ELEMENT_REGISTRY[name].requiredAttributes) { + expect(prompt, `expected prompt to mention required attr "${attr}" of <${name}>`).to.include(`\`${attr}\``) + } + } + }) + }) }) diff --git a/test/unit/server/infra/render/elements/bv-diagram.test.ts b/test/unit/server/infra/render/elements/bv-diagram.test.ts new file mode 100644 index 000000000..a6cc4d76d --- /dev/null +++ b/test/unit/server/infra/render/elements/bv-diagram.test.ts @@ -0,0 +1,55 @@ +/** + * bv-diagram validator tests. + * + * Preserves a diagram (mermaid / plantuml / ascii / dot) verbatim. + * - `type` — optional; one of {"mermaid","plantuml","ascii","dot", + * "graphviz","other"} + * - `title` — optional; caption string + */ + +import {expect} from 'chai' + +import type {ElementNode} from '../../../../../../src/server/core/domain/render/element-types.js' + +import {validateBvDiagram} from '../../../../../../src/server/infra/render/elements/bv-diagram/validator.js' + +function makeNode(attributes: Record, tagName = 'bv-diagram'): ElementNode { + return {attributes, children: [], tagName, type: 'element'} +} + +describe('bv-diagram validator', () => { + describe('valid', () => { + it('accepts an empty attribute set', () => { + expect(validateBvDiagram(makeNode({})).valid).to.equal(true) + }) + + it('accepts every type-enum value', () => { + for (const t of ['mermaid', 'plantuml', 'ascii', 'dot', 'graphviz', 'other']) { + expect(validateBvDiagram(makeNode({type: t})).valid, `expected ${t} to be accepted`).to.equal(true) + } + }) + + it('accepts type + title together', () => { + expect(validateBvDiagram(makeNode({title: 'Authentication Flow', type: 'mermaid'})).valid).to.equal(true) + }) + + it('tolerates unknown attributes (parse-and-skip — M1 light validation)', () => { + expect(validateBvDiagram(makeNode({someFutureAttr: 'x', type: 'mermaid'})).valid).to.equal(true) + }) + }) + + describe('invalid', () => { + it('rejects unknown type-enum value', () => { + expect(validateBvDiagram(makeNode({type: 'sequence'})).valid).to.equal(false) + }) + + it('rejects type in wrong case (case-sensitive enum)', () => { + expect(validateBvDiagram(makeNode({type: 'Mermaid'})).valid).to.equal(false) + }) + + it('rejects wrong tag name', () => { + const result = validateBvDiagram(makeNode({}, 'bv-rule')) + expect(result.valid).to.equal(false) + }) + }) +}) diff --git a/test/unit/server/infra/render/elements/bv-fact.test.ts b/test/unit/server/infra/render/elements/bv-fact.test.ts new file mode 100644 index 000000000..906ab4482 --- /dev/null +++ b/test/unit/server/infra/render/elements/bv-fact.test.ts @@ -0,0 +1,62 @@ +/** + * bv-fact validator tests. + * + * A structured fact entry. Mirrors the existing fact model: + * - `subject` — optional; snake_case key (e.g., "user_name") + * - `category` — optional; one of {"personal","project","preference", + * "convention","team","environment","other"} + * - `value` — optional; the extracted value + * + * The element's text content is the canonical statement. + */ + +import {expect} from 'chai' + +import type {ElementNode} from '../../../../../../src/server/core/domain/render/element-types.js' + +import {validateBvFact} from '../../../../../../src/server/infra/render/elements/bv-fact/validator.js' + +function makeNode(attributes: Record, tagName = 'bv-fact'): ElementNode { + return {attributes, children: [], tagName, type: 'element'} +} + +describe('bv-fact validator', () => { + describe('valid', () => { + it('accepts an empty attribute set (statement-only fact)', () => { + expect(validateBvFact(makeNode({})).valid).to.equal(true) + }) + + it('accepts every category-enum value', () => { + for (const c of ['personal', 'project', 'preference', 'convention', 'team', 'environment', 'other']) { + expect(validateBvFact(makeNode({category: c})).valid, `expected ${c} to be accepted`).to.equal(true) + } + }) + + it('accepts subject + category + value together', () => { + expect(validateBvFact(makeNode({ + category: 'project', + subject: 'database_version', + value: 'PostgreSQL 15', + })).valid).to.equal(true) + }) + + it('tolerates unknown attributes (parse-and-skip — M1 light validation)', () => { + expect(validateBvFact(makeNode({category: 'project', someFutureAttr: 'x'})).valid).to.equal(true) + }) + }) + + describe('invalid', () => { + it('rejects unknown category-enum value', () => { + expect(validateBvFact(makeNode({category: 'critical'})).valid).to.equal(false) + }) + + it('rejects category in wrong case (case-sensitive enum)', () => { + expect(validateBvFact(makeNode({category: 'Project'})).valid).to.equal(false) + }) + + it('rejects wrong tag name', () => { + const result = validateBvFact(makeNode({}, 'bv-rule')) + expect(result.valid).to.equal(false) + }) + }) +}) diff --git a/test/unit/server/infra/render/elements/bv-topic.test.ts b/test/unit/server/infra/render/elements/bv-topic.test.ts index 86e5b3ef5..7b6d90265 100644 --- a/test/unit/server/infra/render/elements/bv-topic.test.ts +++ b/test/unit/server/infra/render/elements/bv-topic.test.ts @@ -1,12 +1,19 @@ /** * bv-topic validator tests. * - * The root container element. Carries file-level metadata as attributes: + * The root container element. Carries frontmatter as attributes: * - `path` — required; non-empty string identifying the topic - * - `importance` — optional; integer string "0".."100" - * - `maturity` — optional; one of {"draft","validated","core"} - * - `recency` — optional; numeric string "0".."1" - * - `updatedat` — optional; ISO-8601 datetime + * - `title` — required; non-empty string + * - `summary` — optional; one-line summary (any non-empty string) + * - `tags` — optional; comma-separated category tags + * - `keywords` — optional; comma-separated retrieval keywords + * - `related` — optional; comma-separated `@domain/topic` cross-refs + * + * Notably absent: `importance`, `maturity`, `recency`, `updatedat`, + * `createdAt`. Per the runtime-signals migration these are sidecar + * state — per-user / per-machine — not file content. Including them + * here would re-introduce the noise-from-implicit-state problem the + * migration solved. * * Light validation per M1 (ADR-007 §13 strict validation is M2). * Unknown attributes are tolerated (parse-and-skip — no warning emitted in @@ -25,41 +32,42 @@ function makeNode(attributes: Record, tagName = 'bv-topic'): Ele describe('bv-topic validator', () => { describe('valid', () => { - it('accepts the minimum: only `path` set', () => { - const result = validateBvTopic(makeNode({path: 'security/auth'})) + it('accepts the minimum: `path` + `title`', () => { + const result = validateBvTopic(makeNode({path: 'security/auth', title: 'JWT auth'})) expect(result.valid).to.equal(true) }) - it('accepts all optional attributes set together', () => { + it('accepts all frontmatter attributes set together', () => { const result = validateBvTopic(makeNode({ - importance: '89', - maturity: 'core', + keywords: 'jwt,refresh,token', path: 'security/auth', - recency: '0.97', - updatedat: '2026-04-27T08:17:42Z', + related: '@security/cookies,@security/oauth', + summary: 'JWT auth design overview', + tags: 'security,authentication', + title: 'JWT auth', })) expect(result.valid).to.equal(true) }) it('tolerates unknown attributes (parse-and-skip — M1 light validation)', () => { - const result = validateBvTopic(makeNode({path: 'x', someFutureAttr: 'whatever'})) - expect(result.valid).to.equal(true) - }) - - it('accepts importance = "0"', () => { - const result = validateBvTopic(makeNode({importance: '0', path: 'x'})) + const result = validateBvTopic(makeNode({path: 'x', someFutureAttr: 'whatever', title: 't'})) expect(result.valid).to.equal(true) }) - it('accepts importance = "100"', () => { - const result = validateBvTopic(makeNode({importance: '100', path: 'x'})) + it('tolerates empty list-shaped attributes', () => { + const result = validateBvTopic(makeNode({ + keywords: '', + path: 'x', + tags: '', + title: 't', + })) expect(result.valid).to.equal(true) }) }) describe('invalid', () => { it('rejects missing `path`', () => { - const result = validateBvTopic(makeNode({})) + const result = validateBvTopic(makeNode({title: 't'})) expect(result.valid).to.equal(false) if (!result.valid) { expect(result.errors.some((e) => e.field === 'path')).to.equal(true) @@ -67,76 +75,50 @@ describe('bv-topic validator', () => { }) it('rejects empty `path`', () => { - const result = validateBvTopic(makeNode({path: ''})) - expect(result.valid).to.equal(false) - }) - - it('rejects non-numeric importance', () => { - const result = validateBvTopic(makeNode({importance: 'high', path: 'x'})) - expect(result.valid).to.equal(false) - }) - - it('rejects out-of-range importance (>100)', () => { - const result = validateBvTopic(makeNode({importance: '101', path: 'x'})) - expect(result.valid).to.equal(false) - }) - - it('rejects out-of-range importance (negative)', () => { - const result = validateBvTopic(makeNode({importance: '-1', path: 'x'})) - expect(result.valid).to.equal(false) - }) - - it('rejects unknown maturity tier', () => { - const result = validateBvTopic(makeNode({maturity: 'experimental', path: 'x'})) + const result = validateBvTopic(makeNode({path: '', title: 't'})) expect(result.valid).to.equal(false) }) - it('rejects malformed updatedat', () => { - const result = validateBvTopic(makeNode({path: 'x', updatedat: 'yesterday'})) - expect(result.valid).to.equal(false) - }) - - it('accepts updatedat with a positive timezone offset', () => { - // ISO-8601 with explicit offset (e.g. from `git log --date=iso-strict`) - const result = validateBvTopic(makeNode({path: 'x', updatedat: '2026-04-27T08:17:42+02:00'})) - expect(result.valid).to.equal(true) - }) - - it('accepts updatedat with a negative timezone offset', () => { - const result = validateBvTopic(makeNode({path: 'x', updatedat: '2026-04-27T08:17:42-08:00'})) - expect(result.valid).to.equal(true) - }) - - it('rejects pathological recency values that pass the regex but are not numbers', () => { - // The previous regex `[\d.]+` accepts ".", "..1", "1..2" etc. - // Validator should reject these as not-finite-numeric. - expect(validateBvTopic(makeNode({path: 'x', recency: '.'})).valid).to.equal(false) - expect(validateBvTopic(makeNode({path: 'x', recency: '..1'})).valid).to.equal(false) - expect(validateBvTopic(makeNode({path: 'x', recency: '1..2'})).valid).to.equal(false) - }) - - it('accepts well-formed recency values', () => { - expect(validateBvTopic(makeNode({path: 'x', recency: '0'})).valid).to.equal(true) - expect(validateBvTopic(makeNode({path: 'x', recency: '0.5'})).valid).to.equal(true) - expect(validateBvTopic(makeNode({path: 'x', recency: '1'})).valid).to.equal(true) - }) - - it('rejects non-numeric recency', () => { - const result = validateBvTopic(makeNode({path: 'x', recency: 'high'})) + it('rejects missing `title`', () => { + const result = validateBvTopic(makeNode({path: 'x'})) expect(result.valid).to.equal(false) + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'title')).to.equal(true) + } }) - it('rejects recency outside [0, 1]', () => { - const result = validateBvTopic(makeNode({path: 'x', recency: '1.5'})) + it('rejects empty `title`', () => { + const result = validateBvTopic(makeNode({path: 'x', title: ''})) expect(result.valid).to.equal(false) }) it('rejects wrong tag name (defensive — registry should never call wrong validator)', () => { - const result = validateBvTopic(makeNode({path: 'x'}, 'bv-rule')) + const result = validateBvTopic(makeNode({path: 'x', title: 't'}, 'bv-rule')) expect(result.valid).to.equal(false) if (!result.valid) { expect(result.errors.some((e) => e.field === 'tagName')).to.equal(true) } }) }) + + describe('runtime signals are NOT bv-topic attributes', () => { + // These fields lived on bv-topic in an earlier draft. They were + // moved to the runtime-signal sidecar store (per-user, per-machine, + // bumped on every brv query) so re-introducing them here would + // revert that migration. The schema's `passthrough` tolerates them + // gracefully (parse-and-skip) but they should never be authored. + it('passthrough tolerates legacy importance/maturity/recency without enforcing them', () => { + const result = validateBvTopic(makeNode({ + importance: '89', + maturity: 'core', + path: 'x', + recency: '0.97', + title: 't', + updatedat: '2026-04-27T08:17:42Z', + })) + // Tolerated, but no longer enforced — the writer ignores them and + // reads runtime signals from the sidecar instead. + expect(result.valid).to.equal(true) + }) + }) }) diff --git a/test/unit/server/infra/render/elements/registry.test.ts b/test/unit/server/infra/render/elements/registry.test.ts index 328f2d4ef..d528ce358 100644 --- a/test/unit/server/infra/render/elements/registry.test.ts +++ b/test/unit/server/infra/render/elements/registry.test.ts @@ -20,8 +20,8 @@ function makeNode(tagName: string, attributes: Record = {}): Ele describe('ELEMENT_REGISTRY', () => { describe('shape', () => { - it('contains exactly 5 entries (M1 vocabulary)', () => { - expect(Object.keys(ELEMENT_REGISTRY)).to.have.lengthOf(5) + it('contains exactly the M1 vocabulary (16 entries)', () => { + expect(Object.keys(ELEMENT_REGISTRY)).to.have.lengthOf(ELEMENT_NAMES.length) }) it('has one entry per `ElementName` listed in `ELEMENT_NAMES`', () => { @@ -46,7 +46,7 @@ describe('ELEMENT_REGISTRY', () => { describe('validators are wired correctly', () => { it('bv-topic validator accepts a valid bv-topic node', () => { - const result = ELEMENT_REGISTRY['bv-topic'].validator(makeNode('bv-topic', {path: 'x'})) + const result = ELEMENT_REGISTRY['bv-topic'].validator(makeNode('bv-topic', {path: 'x', title: 't'})) expect(result.valid).to.equal(true) }) @@ -74,11 +74,47 @@ describe('ELEMENT_REGISTRY', () => { const result = ELEMENT_REGISTRY['bv-fix'].validator(makeNode('bv-fix')) expect(result.valid).to.equal(true) }) + + it('every registered validator accepts an empty node of its own tag', () => { + // Smoke test that the registry is wired tag-to-validator correctly + // and that every validator's "minimum viable node" passes its own + // schema. bv-topic is excluded — it requires `path` + `title`. + for (const name of ELEMENT_NAMES) { + if (name === 'bv-topic') continue + const result = ELEMENT_REGISTRY[name].validator(makeNode(name)) + expect(result.valid, `expected ${name} to accept its own empty node`).to.equal(true) + } + }) + + it('every registered validator rejects a wrong-tag node (tag-name guard)', () => { + for (const name of ELEMENT_NAMES) { + const result = ELEMENT_REGISTRY[name].validator(makeNode('mismatched-tag')) + expect(result.valid, `expected ${name} validator to reject mismatched-tag`).to.equal(false) + } + }) }) describe('metadata for downstream consumers', () => { - it('bv-topic declares `path` as a required attribute', () => { + it('bv-topic declares `path` and `title` as required attributes', () => { expect(ELEMENT_REGISTRY['bv-topic'].requiredAttributes).to.include('path') + expect(ELEMENT_REGISTRY['bv-topic'].requiredAttributes).to.include('title') + }) + + it('bv-topic declares `summary`, `tags`, `keywords`, `related` as optional', () => { + for (const attr of ['summary', 'tags', 'keywords', 'related']) { + expect(ELEMENT_REGISTRY['bv-topic'].optionalAttributes, `expected ${attr} to be optional`).to.include(attr) + } + }) + + it('bv-topic does NOT declare runtime signals (importance/maturity/recency/updatedat) as schema attributes', () => { + // These are sidecar state per the runtime-signals migration. + const allDeclared = [ + ...ELEMENT_REGISTRY['bv-topic'].requiredAttributes, + ...ELEMENT_REGISTRY['bv-topic'].optionalAttributes, + ] + for (const sidecarField of ['importance', 'maturity', 'recency', 'updatedat']) { + expect(allDeclared, `expected ${sidecarField} to NOT be a schema attribute`).to.not.include(sidecarField) + } }) it('bv-rule declares `severity` as an optional attribute', () => { diff --git a/test/unit/server/infra/render/elements/text-only-elements.test.ts b/test/unit/server/infra/render/elements/text-only-elements.test.ts new file mode 100644 index 000000000..b555d2e48 --- /dev/null +++ b/test/unit/server/infra/render/elements/text-only-elements.test.ts @@ -0,0 +1,65 @@ +/** + * Validator tests for the M1 attribute-free text-only elements: + * - `` — `## Reason` body section + * - `` — `## Raw Concept > Task` + * - `` — `## Raw Concept > Changes` + * - `` — `## Raw Concept > Files` + * - `` — `## Raw Concept > Flow` + * - `` — `## Narrative > Structure` + * - `` — `## Narrative > Dependencies` + * - `` — `## Narrative > Highlights` + * - `` — `## Narrative > Examples` + * + * These elements all share the same schema shape (no required or + * declared attributes; passthrough tolerates anything). One test file + * exercises the shared invariants without per-element repetition. + */ + +import {expect} from 'chai' + +import type {ElementNode} from '../../../../../../src/server/core/domain/render/element-types.js' + +import {validateBvChanges} from '../../../../../../src/server/infra/render/elements/bv-changes/validator.js' +import {validateBvDependencies} from '../../../../../../src/server/infra/render/elements/bv-dependencies/validator.js' +import {validateBvExamples} from '../../../../../../src/server/infra/render/elements/bv-examples/validator.js' +import {validateBvFiles} from '../../../../../../src/server/infra/render/elements/bv-files/validator.js' +import {validateBvFlow} from '../../../../../../src/server/infra/render/elements/bv-flow/validator.js' +import {validateBvHighlights} from '../../../../../../src/server/infra/render/elements/bv-highlights/validator.js' +import {validateBvReason} from '../../../../../../src/server/infra/render/elements/bv-reason/validator.js' +import {validateBvStructure} from '../../../../../../src/server/infra/render/elements/bv-structure/validator.js' +import {validateBvTask} from '../../../../../../src/server/infra/render/elements/bv-task/validator.js' + +function makeNode(tagName: string, attributes: Record = {}): ElementNode { + return {attributes, children: [], tagName, type: 'element'} +} + +const cases: Array<{name: string; tag: string; validate: (n: ElementNode) => {valid: boolean}}> = [ + {name: 'bv-reason', tag: 'bv-reason', validate: validateBvReason}, + {name: 'bv-task', tag: 'bv-task', validate: validateBvTask}, + {name: 'bv-changes', tag: 'bv-changes', validate: validateBvChanges}, + {name: 'bv-files', tag: 'bv-files', validate: validateBvFiles}, + {name: 'bv-flow', tag: 'bv-flow', validate: validateBvFlow}, + {name: 'bv-structure', tag: 'bv-structure', validate: validateBvStructure}, + {name: 'bv-dependencies', tag: 'bv-dependencies', validate: validateBvDependencies}, + {name: 'bv-highlights', tag: 'bv-highlights', validate: validateBvHighlights}, + {name: 'bv-examples', tag: 'bv-examples', validate: validateBvExamples}, +] + +describe('text-only element validators', () => { + for (const c of cases) { + describe(c.name, () => { + it('accepts an empty attribute set', () => { + expect(c.validate(makeNode(c.tag)).valid).to.equal(true) + }) + + it('tolerates unknown attributes (parse-and-skip — M1 light validation)', () => { + expect(c.validate(makeNode(c.tag, {someFutureAttr: 'x'})).valid).to.equal(true) + }) + + it('rejects wrong tag name (defensive — registry should never miswire)', () => { + const result = c.validate(makeNode('bv-rule')) + expect(result.valid).to.equal(false) + }) + }) + } +}) diff --git a/test/unit/server/infra/render/sample-topic-roundtrip.test.ts b/test/unit/server/infra/render/sample-topic-roundtrip.test.ts index c16a92b4a..ed21fd5ab 100644 --- a/test/unit/server/infra/render/sample-topic-roundtrip.test.ts +++ b/test/unit/server/infra/render/sample-topic-roundtrip.test.ts @@ -1,7 +1,7 @@ /** * Sample-topic round-trip test. * - * Verifies that the M1 5-element vocabulary, applied to a realistic + * Verifies that the M1 element vocabulary, applied to a realistic * topic file, parses cleanly, validates per-element, and round-trips * (parse → walk → re-serialize) without semantic loss. * @@ -42,7 +42,7 @@ describe('sample-topic.html round-trip', () => { expect(topics).to.have.lengthOf(1) }) - it('contains all 5 M1 element types at least once', () => { + it('contains every M1 element type at least once', () => { const elements = walkElements(parseHtml(loadFixture())) const tagSet = new Set(elements.map((e) => e.tagName)) for (const name of ELEMENT_NAMES) { @@ -50,25 +50,25 @@ describe('sample-topic.html round-trip', () => { } }) - it('preserves the bv-topic root attributes', () => { + it('preserves the bv-topic frontmatter attributes', () => { const elements = walkElements(parseHtml(loadFixture())) const topic = elements.find((e) => e.tagName === 'bv-topic')! expect(topic.attributes.path).to.equal('security/auth') - expect(topic.attributes.importance).to.equal('89') - expect(topic.attributes.maturity).to.equal('core') - expect(topic.attributes.updatedat).to.equal('2026-04-27T08:17:42Z') + expect(topic.attributes.title).to.equal('Authentication and Authorization') + expect(topic.attributes.tags).to.equal('security,authentication') + expect(topic.attributes.keywords).to.include('jwt') + expect(topic.attributes.related).to.include('@security/cookies') }) - it('lowercases attribute names per HTML5 spec (updatedAt → updatedat)', () => { - // Regression: the fixture intentionally uses camelCase `updatedAt=` to - // exercise parse5's HTML5 attribute-name normalization. Schemas and - // consumers must look up the lowercase key; the camelCase key must - // not survive parsing. - const fixture = loadFixture() - expect(fixture, 'fixture should contain camelCase source').to.include('updatedAt=') - const topic = walkElements(parseHtml(fixture)).find((e) => e.tagName === 'bv-topic')! - expect(topic.attributes.updatedat).to.not.equal(undefined) - expect(topic.attributes.updatedAt).to.equal(undefined) + it('does NOT carry runtime-signal attributes on bv-topic', () => { + // importance/maturity/recency/updatedat live in the runtime-signal + // sidecar store, not in topic file content. The fixture must not + // re-introduce them. + const elements = walkElements(parseHtml(loadFixture())) + const topic = elements.find((e) => e.tagName === 'bv-topic')! + for (const sidecar of ['importance', 'maturity', 'recency', 'updatedat']) { + expect(topic.attributes[sidecar], `expected ${sidecar} to NOT appear on bv-topic`).to.equal(undefined) + } }) }) @@ -141,4 +141,34 @@ describe('sample-topic.html round-trip', () => { expect(innerText).to.include('logout') }) }) + + describe('renderable-MD coverage', () => { + // The M1 vocabulary's promise: every section the markdown writer + // renders has a dedicated bv-* element. The fixture exercises that + // by including every renderable section at least once. + it('covers every renderable .md section via dedicated elements', () => { + const elements = walkElements(parseHtml(loadFixture())) + const tags = new Set(elements.map((e) => e.tagName)) + // Frontmatter mapping (attributes on bv-topic) is covered by the + // 'preserves the bv-topic frontmatter attributes' test above. + // Body sections live on dedicated elements: + const renderableSections = [ + 'bv-reason', // ## Reason + 'bv-task', // ## Raw Concept > Task + 'bv-changes', // ## Raw Concept > Changes + 'bv-files', // ## Raw Concept > Files + 'bv-flow', // ## Raw Concept > Flow + 'bv-structure', // ## Narrative > Structure + 'bv-dependencies', // ## Narrative > Dependencies + 'bv-highlights', // ## Narrative > Highlights + 'bv-rule', // ## Narrative > Rules (each rule) + 'bv-examples', // ## Narrative > Examples + 'bv-diagram', // ## Narrative > Diagrams (each diagram) + 'bv-fact', // ## Facts (each fact) + ] + for (const tag of renderableSections) { + expect(tags.has(tag), `expected ${tag} to cover its rendered section`).to.equal(true) + } + }) + }) }) From eb470b500af7800aa0db02ce5d75cf42fc3f84e7 Mon Sep 17 00:00:00 2001 From: Danh Doan Date: Sun, 10 May 2026 16:00:18 +0700 Subject: [PATCH 3/3] feat: [ENG-2738] full writer-section coverage + fence sanitiser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second writer audit (markdown-writer.ts:270-354) found 3 rawConcept sub-fields without an HTML home: rawConcept.timestamp, rawConcept.author, rawConcept.patterns. Add the missing elements so the M1 vocabulary covers every section the writer renders. Vocabulary additions (16 → 19 elements): - — text-only; renders as `**Timestamp:**` under `## Raw Concept`. Distinct from frontmatter createdAt/updatedAt (system-set) — this is the date the concept's data represents. - — text-only; renders as `**Author:**`. - — structured (flags + description as attrs, pattern as text content). Multiple siblings inside collected into the `**Patterns:**` bullet list. Mirrors how works. Sanitiser: - stripCodeFenceWrapper(html) added to html-parser.ts. Strips a single outer ``` ... ``` (any language tag) wrapper from the LLM response. Inner fences (e.g.,
       blocks inside ) survive.
        T3's curate executor calls this before parseHtml. Defensive
        sanitisation generalises better than chasing the model's quirk via
        prompt iteration — observed fence-wrap rate is 70% on Sonnet 4.5
        even with explicit prompt instruction to the contrary.
      
      Tests:
      
      - bv-pattern dedicated test (bv-pattern.test.ts) for the structured
        attrs.
      - text-only-elements.test.ts extended for bv-timestamp + bv-author
        (same trivial schema as the other text-only elements).
      - html-parser.test.ts: 7 new cases for stripCodeFenceWrapper covering
        ```html, ```xml, no-language ```, leading/trailing whitespace,
        unwrapped passthrough, mismatched-fence passthrough, inner-fence
        preservation, and parse-after-strip end-to-end.
      - sample-topic-roundtrip.test.ts: renderable-MD coverage assertion
        extended to require all 15 dedicated body-section elements (was 12).
      - sample-topic.html fixture exercises the 3 new elements.
      
      Re-ran the M1 T2 fluency check on the same 20 fixtures with the
      19-element vocabulary. Results:
      - 20 / 20 valid (gate cleared)
      - 100% cohort-appropriate placement (unchanged)
      - bv-timestamp 60% adoption (used where input has a clear date)
      - bv-author 10% (used only on inputs that name an owner)
      - bv-pattern 0% — model correctly did not over-emit; no fixture
        contained regex content. Restraint is good behaviour.
      - Cost: $0.61 (vs. $0.60 for the 16-element draft; negligible)
      
      Updated fluency-report.md in research repo with final coverage matrix
      and the 19-element → markdown-writer mapping for T3.
      ---
       src/agent/resources/tools/curate.txt          | 14 +++++
       .../core/domain/render/element-types.ts       | 12 +++-
       .../infra/render/elements/bv-author/schema.ts | 10 ++++
       .../render/elements/bv-author/validator.ts    |  4 ++
       .../render/elements/bv-pattern/schema.ts      | 16 ++++++
       .../render/elements/bv-pattern/validator.ts   |  4 ++
       .../render/elements/bv-timestamp/schema.ts    | 11 ++++
       .../render/elements/bv-timestamp/validator.ts |  4 ++
       src/server/infra/render/elements/registry.ts  | 37 ++++++++++++
       src/server/infra/render/reader/html-parser.ts | 23 ++++++++
       test/fixtures/render/sample-topic.html        |  3 +
       .../infra/render/elements/bv-pattern.test.ts  | 50 ++++++++++++++++
       .../elements/text-only-elements.test.ts       |  6 ++
       .../infra/render/reader/html-parser.test.ts   | 57 ++++++++++++++++++-
       .../render/sample-topic-roundtrip.test.ts     |  3 +
       15 files changed, 250 insertions(+), 4 deletions(-)
       create mode 100644 src/server/infra/render/elements/bv-author/schema.ts
       create mode 100644 src/server/infra/render/elements/bv-author/validator.ts
       create mode 100644 src/server/infra/render/elements/bv-pattern/schema.ts
       create mode 100644 src/server/infra/render/elements/bv-pattern/validator.ts
       create mode 100644 src/server/infra/render/elements/bv-timestamp/schema.ts
       create mode 100644 src/server/infra/render/elements/bv-timestamp/validator.ts
       create mode 100644 test/unit/server/infra/render/elements/bv-pattern.test.ts
      
      diff --git a/src/agent/resources/tools/curate.txt b/src/agent/resources/tools/curate.txt
      index 4600b40a9..6e59c25f6 100644
      --- a/src/agent/resources/tools/curate.txt
      +++ b/src/agent/resources/tools/curate.txt
      @@ -78,6 +78,20 @@ metadata):
       `` — `## Raw Concept > Flow:`. The process flow, workflow, or
         step sequence (one paragraph or arrow-style).
       
      +`` — `## Raw Concept > Timestamp:`. The date the concept's
      +  data represents (distinct from frontmatter `createdAt`/`updatedAt`,
      +  which are system-set). Use ISO-8601 (e.g., `2026-04-19`) when known.
      +
      +`` — `## Raw Concept > Author:`. The person or system
      +  identifier responsible for the concept (when knowable from context).
      +
      +`` — bullet entry under `## Raw Concept > Patterns:`.
      +  The pattern itself is the element's text content; structured fields
      +  are attributes. Multiple `` siblings inside ``
      +  are collected into a single bullet list.
      +  - optional `flags` — regex-style flag string (e.g., `"g"`, `"im"`).
      +  - optional `description` — what the pattern matches.
      +
       The following six elements form the `## Narrative` block (descriptive
       context):
       
      diff --git a/src/server/core/domain/render/element-types.ts b/src/server/core/domain/render/element-types.ts
      index 0a716bff4..1b458238f 100644
      --- a/src/server/core/domain/render/element-types.ts
      +++ b/src/server/core/domain/render/element-types.ts
      @@ -21,9 +21,12 @@
        *   bv-topic        — root container; carries frontmatter as attributes.
        *   bv-reason       — `## Reason` body section.
        *   bv-task,        — `## Raw Concept` sub-fields:
      - *   bv-changes,       Task / Changes / Files / Flow.
      - *   bv-files,
      - *   bv-flow
      + *   bv-changes,       Task / Changes / Files / Flow / Timestamp /
      + *   bv-files,         Author / Patterns. (One sibling per emitted
      + *   bv-flow,          bullet-label; multiple  permitted.)
      + *   bv-timestamp,
      + *   bv-author,
      + *   bv-pattern
        *   bv-structure,   — `## Narrative` sub-fields:
        *   bv-dependencies,  Structure / Dependencies / Highlights /
        *   bv-highlights,    Rules / Examples / Diagrams.
      @@ -44,6 +47,9 @@ export const ELEMENT_NAMES = [
         'bv-changes',
         'bv-files',
         'bv-flow',
      +  'bv-timestamp',
      +  'bv-author',
      +  'bv-pattern',
         'bv-structure',
         'bv-dependencies',
         'bv-highlights',
      diff --git a/src/server/infra/render/elements/bv-author/schema.ts b/src/server/infra/render/elements/bv-author/schema.ts
      new file mode 100644
      index 000000000..5eab77d20
      --- /dev/null
      +++ b/src/server/infra/render/elements/bv-author/schema.ts
      @@ -0,0 +1,10 @@
      +import {z} from 'zod'
      +
      +/**
      + * Zod schema for `` attributes.
      + *
      + * Renders as `**Author:**` inside the `## Raw Concept` section — the
      + * person or system identifier responsible for the concept. Free-form
      + * string content.
      + */
      +export const BvAuthorAttributesSchema = z.object({}).passthrough()
      diff --git a/src/server/infra/render/elements/bv-author/validator.ts b/src/server/infra/render/elements/bv-author/validator.ts
      new file mode 100644
      index 000000000..d58ce5ca4
      --- /dev/null
      +++ b/src/server/infra/render/elements/bv-author/validator.ts
      @@ -0,0 +1,4 @@
      +import {makeAttributeValidator} from '../make-validator.js'
      +import {BvAuthorAttributesSchema} from './schema.js'
      +
      +export const validateBvAuthor = makeAttributeValidator('bv-author', BvAuthorAttributesSchema)
      diff --git a/src/server/infra/render/elements/bv-pattern/schema.ts b/src/server/infra/render/elements/bv-pattern/schema.ts
      new file mode 100644
      index 000000000..f64c5f5c1
      --- /dev/null
      +++ b/src/server/infra/render/elements/bv-pattern/schema.ts
      @@ -0,0 +1,16 @@
      +import {z} from 'zod'
      +
      +/**
      + * Zod schema for `` attributes.
      + *
      + * Renders as a bullet entry inside `**Patterns:**` (under `## Raw Concept`).
      + * The pattern itself is the element's text content; structured fields
      + * (flags, description) are attributes. Multiple `` siblings
      + * inside `` are collected into a single bullet list.
      + *
      + *   [\w.+-]+@[\w.-]+
      + */
      +export const BvPatternAttributesSchema = z.object({
      +  description: z.string().optional(),
      +  flags: z.string().optional(),
      +}).passthrough()
      diff --git a/src/server/infra/render/elements/bv-pattern/validator.ts b/src/server/infra/render/elements/bv-pattern/validator.ts
      new file mode 100644
      index 000000000..985c53411
      --- /dev/null
      +++ b/src/server/infra/render/elements/bv-pattern/validator.ts
      @@ -0,0 +1,4 @@
      +import {makeAttributeValidator} from '../make-validator.js'
      +import {BvPatternAttributesSchema} from './schema.js'
      +
      +export const validateBvPattern = makeAttributeValidator('bv-pattern', BvPatternAttributesSchema)
      diff --git a/src/server/infra/render/elements/bv-timestamp/schema.ts b/src/server/infra/render/elements/bv-timestamp/schema.ts
      new file mode 100644
      index 000000000..bf2872c91
      --- /dev/null
      +++ b/src/server/infra/render/elements/bv-timestamp/schema.ts
      @@ -0,0 +1,11 @@
      +import {z} from 'zod'
      +
      +/**
      + * Zod schema for `` attributes.
      + *
      + * Renders as `**Timestamp:**` inside the `## Raw Concept` section — the
      + * date the concept's data represents (distinct from the file's
      + * createdAt/updatedAt frontmatter, which is system-set). Free-form
      + * string content (typically ISO-8601 or short date).
      + */
      +export const BvTimestampAttributesSchema = z.object({}).passthrough()
      diff --git a/src/server/infra/render/elements/bv-timestamp/validator.ts b/src/server/infra/render/elements/bv-timestamp/validator.ts
      new file mode 100644
      index 000000000..57e9b7cf7
      --- /dev/null
      +++ b/src/server/infra/render/elements/bv-timestamp/validator.ts
      @@ -0,0 +1,4 @@
      +import {makeAttributeValidator} from '../make-validator.js'
      +import {BvTimestampAttributesSchema} from './schema.js'
      +
      +export const validateBvTimestamp = makeAttributeValidator('bv-timestamp', BvTimestampAttributesSchema)
      diff --git a/src/server/infra/render/elements/registry.ts b/src/server/infra/render/elements/registry.ts
      index ec615cc53..cd4ca9d94 100644
      --- a/src/server/infra/render/elements/registry.ts
      +++ b/src/server/infra/render/elements/registry.ts
      @@ -1,5 +1,6 @@
       import type {ElementRegistry} from '../../../core/domain/render/element-types.js'
       
      +import {validateBvAuthor} from './bv-author/validator.js'
       import {validateBvBug} from './bv-bug/validator.js'
       import {validateBvChanges} from './bv-changes/validator.js'
       import {validateBvDecision} from './bv-decision/validator.js'
      @@ -11,10 +12,12 @@ import {validateBvFiles} from './bv-files/validator.js'
       import {validateBvFix} from './bv-fix/validator.js'
       import {validateBvFlow} from './bv-flow/validator.js'
       import {validateBvHighlights} from './bv-highlights/validator.js'
      +import {validateBvPattern} from './bv-pattern/validator.js'
       import {validateBvReason} from './bv-reason/validator.js'
       import {validateBvRule} from './bv-rule/validator.js'
       import {validateBvStructure} from './bv-structure/validator.js'
       import {validateBvTask} from './bv-task/validator.js'
      +import {validateBvTimestamp} from './bv-timestamp/validator.js'
       import {validateBvTopic} from './bv-topic/validator.js'
       
       /**
      @@ -35,6 +38,16 @@ import {validateBvTopic} from './bv-topic/validator.js'
        * in the sidecar store keyed by relpath — not in topic file content.
        */
       export const ELEMENT_REGISTRY: ElementRegistry = {
      +  'bv-author': {
      +    allowedChildren: 'inline',
      +    description:
      +      'Renders as `**Author:**` inside the `## Raw Concept` section — the ' +
      +      'person or system identifier responsible for the concept.',
      +    name: 'bv-author',
      +    optionalAttributes: [],
      +    requiredAttributes: [],
      +    validator: validateBvAuthor,
      +  },
         'bv-bug': {
           allowedChildren: 'block',
           description:
      @@ -148,6 +161,19 @@ export const ELEMENT_REGISTRY: ElementRegistry = {
           requiredAttributes: [],
           validator: validateBvHighlights,
         },
      +  'bv-pattern': {
      +    allowedChildren: 'inline',
      +    description:
      +      'Renders as a bullet entry under `**Patterns:**` in the `## Raw ' +
      +      'Concept` section. Element text content is the pattern itself ' +
      +      '(e.g., a regex). Optional `flags` and `description` attributes ' +
      +      'carry the structured fields. Multiple `` siblings ' +
      +      'inside `` are collected into the bullet list.',
      +    name: 'bv-pattern',
      +    optionalAttributes: ['flags', 'description'],
      +    requiredAttributes: [],
      +    validator: validateBvPattern,
      +  },
         'bv-reason': {
           allowedChildren: 'block',
           description:
      @@ -188,6 +214,17 @@ export const ELEMENT_REGISTRY: ElementRegistry = {
           requiredAttributes: [],
           validator: validateBvTask,
         },
      +  'bv-timestamp': {
      +    allowedChildren: 'inline',
      +    description:
      +      'Renders as `**Timestamp:**` inside the `## Raw Concept` section — ' +
      +      'the date the concept\'s data represents (distinct from the file\'s ' +
      +      'createdAt/updatedAt frontmatter, which is system-set).',
      +    name: 'bv-timestamp',
      +    optionalAttributes: [],
      +    requiredAttributes: [],
      +    validator: validateBvTimestamp,
      +  },
         'bv-topic': {
           allowedChildren: 'any',
           description:
      diff --git a/src/server/infra/render/reader/html-parser.ts b/src/server/infra/render/reader/html-parser.ts
      index 0e8364b8a..de879eddb 100644
      --- a/src/server/infra/render/reader/html-parser.ts
      +++ b/src/server/infra/render/reader/html-parser.ts
      @@ -24,6 +24,29 @@ type Parse5Node = DefaultTreeAdapterMap['node']
       type Parse5Element = DefaultTreeAdapterMap['element']
       type Parse5TextNode = DefaultTreeAdapterMap['textNode']
       
      +/**
      + * Strip a single ` ```? … ``` ` code-fence wrapper from the input.
      + *
      + * Sonnet 4.5 (and other models) wrap their HTML response in a code fence
      + * even when the prompt explicitly forbids it — observed at ~90% on M1's
      + * authoring fluency check. The fence is cosmetic; the inner HTML still
      + * parses and validates. Defensive sanitisation in the response parser
      + * generalises better than chasing the model's quirk via prompt iteration.
      + *
      + * Behaviour:
      + *   - Input wrapped in ` ```? \n … \n ``` ` → returns inner content.
      + *   - Input not fence-wrapped → returns input unchanged.
      + *   - Trailing/leading whitespace around the wrapper is tolerated.
      + *
      + * Only strips ONE outer fence. Inner fences (e.g., `
      ` blocks
      + * inside ``) survive intact.
      + */
      +export function stripCodeFenceWrapper(html: string): string {
      +  const trimmed = html.trim()
      +  const match = trimmed.match(/^```\w*\s*\n([\s\S]*?)\n```\s*$/)
      +  return match ? match[1] : html
      +}
      +
       /**
        * Parse an HTML string into a normalized `DocumentNode`. parse5's
        * forgiving mode means malformed input returns a best-effort tree
      diff --git a/test/fixtures/render/sample-topic.html b/test/fixtures/render/sample-topic.html
      index 054d366c0..b54c515d1 100644
      --- a/test/fixtures/render/sample-topic.html
      +++ b/test/fixtures/render/sample-topic.html
      @@ -13,6 +13,9 @@
           
    23. test/integration/auth/logout-revocation.test.ts
    24. request → verify access token → on 401 client calls /auth/refresh → server checks revocation cache → rotates both tokens → responds with new pair in httpOnly cookies. + 2026-04-15 + auth-team + ^Bearer\s+([A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+)$ JWT auth issues access/refresh token pairs. Access tokens expire in 15min, refresh tokens in 24h. Tokens are stored as httpOnly Secure SameSite=Strict cookies. Refresh rotation evicts the prior token from the revocation cache. jsonwebtoken for signing/verification; the revocation cache (Redis) for token denylist; httpOnly cookie support in the application framework. diff --git a/test/unit/server/infra/render/elements/bv-pattern.test.ts b/test/unit/server/infra/render/elements/bv-pattern.test.ts new file mode 100644 index 000000000..5ed291754 --- /dev/null +++ b/test/unit/server/infra/render/elements/bv-pattern.test.ts @@ -0,0 +1,50 @@ +/** + * bv-pattern validator tests. + * + * One pattern entry inside `## Raw Concept > Patterns`. Multiple + * `` siblings are collected by the writer into a single + * bullet list. Element text is the pattern itself; structured fields + * live in attributes. + * - `flags` — optional; e.g. "g", "im" + * - `description` — optional; what the pattern matches + */ + +import {expect} from 'chai' + +import type {ElementNode} from '../../../../../../src/server/core/domain/render/element-types.js' + +import {validateBvPattern} from '../../../../../../src/server/infra/render/elements/bv-pattern/validator.js' + +function makeNode(attributes: Record, tagName = 'bv-pattern'): ElementNode { + return {attributes, children: [], tagName, type: 'element'} +} + +describe('bv-pattern validator', () => { + describe('valid', () => { + it('accepts an empty attribute set (pattern-only)', () => { + expect(validateBvPattern(makeNode({})).valid).to.equal(true) + }) + + it('accepts flags + description together', () => { + expect(validateBvPattern(makeNode({ + description: 'Match an email address', + flags: 'gi', + })).valid).to.equal(true) + }) + + it('accepts description only', () => { + expect(validateBvPattern(makeNode({description: 'Match a URL'})).valid).to.equal(true) + }) + + it('tolerates unknown attributes (parse-and-skip — M1 light validation)', () => { + expect(validateBvPattern(makeNode({flags: 'g', someFutureAttr: 'x'})).valid).to.equal(true) + }) + }) + + describe('invalid', () => { + it('rejects wrong tag name', () => { + const result = validateBvPattern(makeNode({}, 'bv-rule')) + expect(result.valid).to.equal(false) + }) + }) +}) diff --git a/test/unit/server/infra/render/elements/text-only-elements.test.ts b/test/unit/server/infra/render/elements/text-only-elements.test.ts index b555d2e48..a39d998e5 100644 --- a/test/unit/server/infra/render/elements/text-only-elements.test.ts +++ b/test/unit/server/infra/render/elements/text-only-elements.test.ts @@ -5,6 +5,8 @@ * - `` — `## Raw Concept > Changes` * - `` — `## Raw Concept > Files` * - `` — `## Raw Concept > Flow` + * - `` — `## Raw Concept > Timestamp` + * - `` — `## Raw Concept > Author` * - `` — `## Narrative > Structure` * - `` — `## Narrative > Dependencies` * - `` — `## Narrative > Highlights` @@ -19,6 +21,7 @@ import {expect} from 'chai' import type {ElementNode} from '../../../../../../src/server/core/domain/render/element-types.js' +import {validateBvAuthor} from '../../../../../../src/server/infra/render/elements/bv-author/validator.js' import {validateBvChanges} from '../../../../../../src/server/infra/render/elements/bv-changes/validator.js' import {validateBvDependencies} from '../../../../../../src/server/infra/render/elements/bv-dependencies/validator.js' import {validateBvExamples} from '../../../../../../src/server/infra/render/elements/bv-examples/validator.js' @@ -28,6 +31,7 @@ import {validateBvHighlights} from '../../../../../../src/server/infra/render/el import {validateBvReason} from '../../../../../../src/server/infra/render/elements/bv-reason/validator.js' import {validateBvStructure} from '../../../../../../src/server/infra/render/elements/bv-structure/validator.js' import {validateBvTask} from '../../../../../../src/server/infra/render/elements/bv-task/validator.js' +import {validateBvTimestamp} from '../../../../../../src/server/infra/render/elements/bv-timestamp/validator.js' function makeNode(tagName: string, attributes: Record = {}): ElementNode { return {attributes, children: [], tagName, type: 'element'} @@ -39,6 +43,8 @@ const cases: Array<{name: string; tag: string; validate: (n: ElementNode) => {va {name: 'bv-changes', tag: 'bv-changes', validate: validateBvChanges}, {name: 'bv-files', tag: 'bv-files', validate: validateBvFiles}, {name: 'bv-flow', tag: 'bv-flow', validate: validateBvFlow}, + {name: 'bv-timestamp', tag: 'bv-timestamp', validate: validateBvTimestamp}, + {name: 'bv-author', tag: 'bv-author', validate: validateBvAuthor}, {name: 'bv-structure', tag: 'bv-structure', validate: validateBvStructure}, {name: 'bv-dependencies', tag: 'bv-dependencies', validate: validateBvDependencies}, {name: 'bv-highlights', tag: 'bv-highlights', validate: validateBvHighlights}, diff --git a/test/unit/server/infra/render/reader/html-parser.test.ts b/test/unit/server/infra/render/reader/html-parser.test.ts index 7d6b5ba8c..e3d719ad9 100644 --- a/test/unit/server/infra/render/reader/html-parser.test.ts +++ b/test/unit/server/infra/render/reader/html-parser.test.ts @@ -18,7 +18,7 @@ import {expect} from 'chai' import type {ElementNode} from '../../../../../../src/server/core/domain/render/element-types.js' -import {getInnerText, parseHtml, serializeHtml, walkElements} from '../../../../../../src/server/infra/render/reader/html-parser.js' +import {getInnerText, parseHtml, serializeHtml, stripCodeFenceWrapper, walkElements} from '../../../../../../src/server/infra/render/reader/html-parser.js' describe('html-parser', () => { describe('parseHtml', () => { @@ -219,4 +219,59 @@ describe('serializeHtml', () => { expect(() => serializeHtml(tree)).to.not.throw() }) }) + +describe('stripCodeFenceWrapper', () => { + it('strips ```html fences wrapping the whole input', () => { + const wrapped = '```html\n\n```' + const stripped = stripCodeFenceWrapper(wrapped) + expect(stripped).to.equal('') + }) + + it('strips ``` (no language tag) fences', () => { + const wrapped = '```\n\n```' + const stripped = stripCodeFenceWrapper(wrapped) + expect(stripped).to.equal('') + }) + + it('strips fences with arbitrary language tags (xml, etc.)', () => { + const wrapped = '```xml\n\n```' + const stripped = stripCodeFenceWrapper(wrapped) + expect(stripped).to.equal('') + }) + + it('tolerates leading and trailing whitespace around the fence', () => { + const wrapped = '\n\n ```html\n\n``` \n' + const stripped = stripCodeFenceWrapper(wrapped) + expect(stripped).to.equal('') + }) + + it('returns input unchanged when no fence is present', () => { + const html = '' + expect(stripCodeFenceWrapper(html)).to.equal(html) + }) + + it('returns input unchanged when only an opening fence (mismatched) is present', () => { + const partial = '```html\n' + expect(stripCodeFenceWrapper(partial)).to.equal(partial) + }) + + it('preserves inner ```code blocks (only strips the OUTER wrapper)', () => { + // bv-diagram content frequently includes
      ...
      + // but the model wraps the whole response in a fence. We must strip + // the outer wrapper without mangling inner content. + const wrapped = '```html\n
      A --> B
      \n```' + const stripped = stripCodeFenceWrapper(wrapped) + expect(stripped).to.include('
      A --> B
      ') + expect(stripped.startsWith('')).to.equal(true) + }) + + it('the stripped output parses correctly (end-to-end smoke)', () => { + const wrapped = '```html\nr\n```' + const stripped = stripCodeFenceWrapper(wrapped) + const elements = walkElements(parseHtml(stripped)) + expect(elements.find((e) => e.tagName === 'bv-topic')).to.not.equal(undefined) + expect(elements.find((e) => e.tagName === 'bv-rule')).to.not.equal(undefined) + }) +}) }) diff --git a/test/unit/server/infra/render/sample-topic-roundtrip.test.ts b/test/unit/server/infra/render/sample-topic-roundtrip.test.ts index ed21fd5ab..8cc7e26d7 100644 --- a/test/unit/server/infra/render/sample-topic-roundtrip.test.ts +++ b/test/unit/server/infra/render/sample-topic-roundtrip.test.ts @@ -158,6 +158,9 @@ describe('sample-topic.html round-trip', () => { 'bv-changes', // ## Raw Concept > Changes 'bv-files', // ## Raw Concept > Files 'bv-flow', // ## Raw Concept > Flow + 'bv-timestamp', // ## Raw Concept > Timestamp + 'bv-author', // ## Raw Concept > Author + 'bv-pattern', // ## Raw Concept > Patterns (each pattern) 'bv-structure', // ## Narrative > Structure 'bv-dependencies', // ## Narrative > Dependencies 'bv-highlights', // ## Narrative > Highlights