Skip to content

Two-layer model

Authoring a PRD has two distinct modes:

  • Writing what and why in prose. Optimised for humans skimming, for LLMs reading.
  • Writing how in structure. Optimised for codegen consuming, for tools validating.

Cramming both into one field forces compromise. AtomPRD splits them.

Available on every atom kind. The shape is the same everywhere:

{
"intent": {
"userStory": "<prose, optional>",
"relatedAtoms": ["<atom_id>", ...],
"notes": "<commentary, optional>"
}
}

Free-form, prose-friendly, no schema constraints beyond required-string semantics. Vietnamese, English, Markdown — whatever. This is the source of truth for what this atom is. The LLM Pattern D suggester reads it as part of every prompt.

Available on 5 atom kinds out of the 26:

KindBehavior shape
feature{ trigger?, steps: Step[], onOk?, onErr? } — Step is validate, call, dispatch, setState, navigate, log, or if.
ui_view{ rootScreen, screens: { [id]: { sections: Section[] } } } — Section is heading, text, list, form, detail, button, input, switch.
rule{ enforced_at: EnforcedAt[] }ui_disabled, pre_call, or validator.
criterion{ before_after: { state_diff, api_calls, derived_diff? } }
fixture{ shape, before?, after?, api_calls? }

These five are precisely the kinds where codegen needs structure. A feature lowers into an action chain. A ui_view lowers into a screen tree. A criterion lowers into a scenario fixture. Without structure, codegen has to guess.

The other 21 kinds (vision, persona, module, entity, field, integration, plus the 11 content blocks and 6 DevOps blocks) are descriptive. Their semantics are fully captured by their kind-specific top-level fields — no need for a structured Behavior layer.

Three reasons.

1. PMs author Layer 1 before engineers fill Layer 2

Section titled “1. PMs author Layer 1 before engineers fill Layer 2”

A PM writes a feature’s user story in five minutes. Filling in the action chain takes engineering judgement. Splitting the layers means:

  • The PM saves a feature with intent.userStory filled and behavior empty. That’s a valid atom.
  • An engineer (or LLM) fills behavior later. The PM’s prose stays untouched.
  • Codegen falls back to Pattern B convention defaults when behavior is empty ({ $do: log, level: warn, msg: "TODO ..." }).

Refining the prose of a user story should not show up as a diff against the action chain. Splitting layers means a Layer 1 edit and a Layer 2 edit are separate jsonb columns in storage and separate audit-log entries.

Once the schema is fixed, codegen tools can write a pure function:

function lowerStep(step: Step): SenAction {
switch (step.kind) {
case "setState": return { $do: "setState", path: step.path, value: step.value };
case "call": return { $do: "callApi", endpoint: step.endpoint, /* ... */ };
// ...
}
}

Same input → same output. No LLM call required to translate. The bridge @atomprd/codegen-senlang is exactly this — about 200 lines of pure TypeScript that walks atoms and lowers each Layer 2 field.

See atomprd/packages/codegen-senlang/src/project/lower.ts for the full lowering table.

A v0.3-conforming atom can have Layer 2 absent. The bridge applies Pattern B convention defaults:

  • feature.behavior absent → events[<slug>] = [{ $do: log, level: "warn", msg: "TODO..." }].
  • ui_view.behavior absent + viewType=list + rendered_by entity → Box + Heading + List + $Each over state.<entitySlot>.
  • rule.behavior absent → rule preserved in rules[<slug>] but not bound to UI / pre-call gates.
  • criterion.behavior absent → scenario emits a TODO stub; schema-valid but senlang test fails until filled.

This is why a PM can save half-filled atoms and the bridge still produces something runnable. See Four patterns → Pattern B.