Engine Invariants
These rules are required. Each rule states how it is checked. The engine lives in
packages/core/src/lib/blocks/. Derived content fields, called projections, live
in packages/core/src/lib/content/.
1. Determinism
The same Markdown must produce the same decisions on every run and in every environment.
The segmenter and rule checks must not read the clock, use randomness, or depend
on locale-specific string behavior.
Enforced: double-run equality test over all fixtures (packages/core/tests/engine.spec.ts, "determinism" suite) in CI.
2. Purity and isomorphism
Code under src/lib/blocks/ and src/lib/content/ must not perform input/output (I/O),
including file reads, file writes, or network requests. It must not access
environment variables. It must not import SvelteKit or browser
DOM code. The server and editor preview use the same engine code and input.
Enforced: no-restricted-imports / no-restricted-globals in packages/core/eslint.config.js, scoped to those directories; violations fail CI, not code review.
3. The hash spec is versioned
Section hashes are FNV-1a 64-bit over normalized text. The normalization (fnv1a64-1): NFC → CRLF/CR→LF → tab/space runs collapse to one space → per-line trim → outer trim. Saved decisions record the hash version they were pinned under. Changing the normalization or hash function is a migration event (bump HASH_VERSION in versions.ts), not a refactor.
Enforced: literal-value tests in packages/core/tests/hash.spec.ts lock the spec.
4. Provenance precedence and drift policy
Merging saved decisions over engine defaults: manual > ai > rule. A saved decision whose section hash no longer exists in the current segmentation is drift: the section falls back to the rule default and the orphaned decision is surfaced to the author — never silently applied elsewhere, never silently deleted.
Enforced: mergeDecisions in blocks/decisions.ts; tests in tests/decisions.spec.ts; orphan UI in the preview route.
5. Everything is version-stamped
Every BlockDecision must carry rulesetVersion, segmenterVersion, and
hashVersion. Save the versions that produced each decision. These values let
us trace changes in rendered output back to a rule change.
Enforced: stamped in classify.ts; constants in versions.ts.
6. Rule hygiene
match functions never throw — a throw is a bug treated as no-match (surfaced via the onRuleError callback; the app logs it in dev). No rule reads another rule's output or mutates the section. First-match-wins is the only control flow.
Enforced: try/catch wrapper in classify.ts; the winning rule's props are additionally Zod-validated, so an invalid rule output is also a no-match.
7. Schema evolution policy
Changing a block's Zod schema requires an explicit migration path for saved props. A saved decision whose props fail current-schema parsing falls back to the rule default and is flagged (ClassifiedSection.flagged) — render never crashes on stale data.
Enforced: safeParse in mergeDecisions; test in tests/decisions.spec.ts.
8. AI contract
AI proposals must target section hashes from the current segmentation; stale-hash proposals are rejected outright. AI output is Zod-parsed; invalid output is discarded, never coerced or partially applied. The block catalog in the AI prompt is generated from the rules' describe strings (generateBlockCatalog) — hand-written catalog text is forbidden.
Enforced: acceptAiProposals in blocks/ai.ts; tests in tests/ai.spec.ts.
9. Content is data, never code
The engine must never execute content as code. Parse and validate fenced data,
such as chart CSV or JSON. Do not use eval or interpret the data as instructions.
The renderer must show raw HTML in Markdown as text. Do not use {@html} in block renderers.
10. Known limitations are documented, not patched
The question-detection convention and its locale limits are explicit scope in PAGEHARE_BLOCKS.md. Do not "fix" them with heuristics that break determinism.
11. Generated doc regions are inviolable
The rules and fence-tag tables in PAGEHARE_BLOCKS.md live between <!-- BEGIN/END GENERATED: … --> markers. Do not edit content inside these markers by hand.
Enforced: npm run docs:blocks:check in CI.
12. Fixture coverage is structural
Every rule ships with fixtures; every segmenter exception (hero-lead) and termination case (noise mid-run, trailing unanswered question) has a dedicated boundary fixture. The runner globs packages/core/tests/fixtures/*/ — adding a rule without a fixture means adding a rule the runner can't see exercised, and review rejects it.
13. Record projections are pure derivations
textContent, description, and com.pagehare.document are produced by pure functions over the mdast of content.markdown, written in the same applyWrites commit, and never read back as inputs to a write. Read paths may display them as stored (description for excerpts and SEO, textContent for word counts) — the markdown stays the only source a write derives from. Facet byte offsets index textContent, so the two are built in a single walk and regenerate together or not at all. A drift bug is repaired by a re-derivation pass over affected records, never by hand-editing projections.
Enforced: round-trip test (derive → re-derive → byte-identical) in tests/projections.spec.ts; write path in adapters/record.ts + adapters/index.ts.
14. mdast is never persisted
Use mdast only while the engine runs. Do not save mdast JSON in the PDS, in PageHare's database, or in a cache that outlives the request or session. Exchange content as Markdown strings. Rich-text block props therefore contain Markdown slices, and the preview cache stores decisions rather than sections.
15. Presentation never enters the PDS
Saved presentation decisions are keyed (document AT-URI, sectionHash, hashVersion, rulesetVersion) and live in PageHare's DecisionStore. Nothing presentation-related — block choices, variants, layout — is ever written into the site.standard.document record.
16. Render is a pure function of its inputs
A published page's HTML is a pure function of (record content, saved decisions, tenant theme) — nothing else, ever. Dynamic or reader-generated state (poll results, counts, any interaction data) is never server-rendered into the page: interactive blocks SSR only their static shell from the fence payload; live state is fetched client-side from a separate, uncacheable endpoint. This is what makes CID-keyed immutable edge caching (zero invalidation logic) possible — an SSR'd vote count is a bug, not a feature.
This constrains the render output, not the request. A read-path side effect that writes nothing into the response is permitted: view counting (August 2026, docs/plans/simple-analytics.md) increments counters during the same load. It is invisible to the HTML, so the render stays pure and the page stays cacheable. The test is not "does the request do anything?" but "could two requests for the same (content, decisions, theme) produce different bytes?" — if yes, it is a violation. Analytics counts therefore render only on author surfaces (dashboard, /stats/[id]), never on /p/[slug].
Enforced: spec-level for now; becomes a review gate when the first stateful block ships.
17. Stateful fences carry stable instance ids
Section hashes key presentation decisions — an edited section should drop its pins. They must never key accumulated state: fixing a typo in a poll question must not orphan its votes. Any fence tag whose block accumulates external state (poll, form, counter) is marked stateful in the fence registry and MUST carry a stable instance id in its payload (generated at insert time). External state is keyed (document AT-URI, instanceId) — never by section hash.
Enforced: registry stateful flag + payload Zod schema requiring id, once such a tag exists.