Skip to content

v1.31.0 ·

Two rules nobody could obey

The problem

AIDLC produces exactly one artefact that is meant to leave the repository. It is a portable skill for writing a roadmap item: a contributor with no checkout, no terminal, and no particular interest in YAML loads it into whatever AI agent they already use, describes what they want, and gets back a correctly formatted file to commit. The whole point is that nobody has to be taught the format.

That skill handed the agent a frontmatter template and one flat rule. Do not add any other field.

Meanwhile the framework's first real external project had added a field of its own, and a check that enforces it. That project keeps requirements traceability as data rather than as prose: a document holds the requirement identifiers, every roadmap item declares which ones it delivers in a frontmatter key, and a script computes coverage as a set difference between the two. The check fails any item missing the key, deliberately, even when the correct value is empty — because a set difference over an optional key does not fail loudly, it under-reports quietly, which is worse.

So the two rules were individually correct and jointly impossible. An agent following the skill exactly produced an item that failed the project's build. An agent satisfying the build had to disobey an explicit instruction in a file stamped as framework-owned, whose header says in as many words that edits to it are overwritten.

Neither side could fix it alone, and that is the part that makes it a framework problem rather than a project one. The project could not patch the template, because the next update would overwrite it — that overwriting is not a bug, it is the mechanism that stops the portable skill drifting out of date, which is the entire reason it is generated rather than committed. And the framework could not anticipate the field, because it has no way to learn that a downstream project invented one.

What made it worse than a one-time annoyance is that it recurred. Every single time somebody wrote an item in that project, a human had to remember the local exception and say so in the prompt. That is the kind of papercut that does not get fixed, it gets avoided: people stop using the skill and hand-write the file, and then the formatting guarantees the skill exists to provide quietly stop applying.

There is one more thing worth recording, because it is uncomfortable rather than flattering. This framework already shipped a piece of work called requirements traceability as data, and it is sitting in the done column. Whatever that delivered evidently does not reach roadmap item frontmatter — which is exactly why the first project to want traceability at the level of an individual item built it locally, by hand, and hit this seam within days of starting. That gap between what the framework thinks it provides and what a project actually needed is more useful information than the bug.

How it could be solved

Six decisions, and the interesting thing about five of them is that the tempting answer was the bigger one. The whole risk in this piece of work was named in the original request before anything was built: small if scoped to a declared list, large if it becomes a general schema extension mechanism. Every option below was judged against that line.

The shallow fix was rejected first and is worth stating because it is the one most people would reach for. Add the field to the stock template. It solves the case in four characters and it is wrong: it hard-codes one project's private concept into a framework used by others, and it relocates the collision to the next project with a different field. The value of getting this right is precisely that the second project does not have to ask.

On where a project declares its fields, a new dedicated configuration file was rejected in favour of a section that already exists. The roadmap section of the project configuration is already read for issue projection, so extending it introduces no new file, no new thing to discover, and no second place where a reader has to look. The reader itself was not written from scratch either — it copies the two boundaries the projection reader already draws, which took a requirements review of its own to settle. A malformed container reads as not configured, because one bad section must never break an unrelated command. Anything wrong inside a well-formed list is a refusal that names itself, because a mistyped value is a mistake somebody made on purpose and wants told about, and reading it as "you have not configured this" sends them looking in the wrong place. Inventing a second convention for reading one file is how two readers come to disagree about it.

On how to describe a field's shape, an explicit type keyword was rejected. It is the obvious design — name the field, say whether it is required, say whether it holds a list or a string — and it was rejected because the default value the project must supply anyway is already a worked example of the shape. A list default means a list. Two representations of one fact drift apart, and this project's accumulated lessons are mostly about exactly that. The cost of the decision is admitted rather than hidden: a field declared with no default gets no shape check at all, because nothing was declared to check it against. That is the honest outcome, not a gap.

On validation, this is where the line was drawn hardest and where the temptation was largest. The framework checks that a declared field is present and that its value is the right kind of thing. It never looks inside. There is no place in the configuration where a project could express a rule about a field's contents, and that absence is the feature rather than an omission — a list of nonsense identifiers is perfectly valid as far as the framework is concerned, and a test asserts exactly that so anybody who later adds content validation fails a test that names the reason. The moment the framework starts adjudicating what goes in the field, it has become the general schema engine the original request warned about.

On what to do about items that already exist, report-only was the position the boundary seemed to demand, and it was rejected. Declaring a field on a project holding fifty items would then report fifty problems and offer no way to act on any of them, which is the shape of gate this project has learned to distrust: a check that is permanently red trains people to ignore the checker. What shipped writes the value the project itself declared as its default, which is repairing an absence with the project's own answer rather than a judgement about meaning. Wrong-shaped values are still only reported and never rewritten, because wrapping a scalar in a list to make it fit is a confident wrong fix, and only the person who wrote the value knows what they meant. That distinction — repair an absence, report a disagreement — is what keeps the backfill inside the boundary.

On whether a declared field should default to required, optional was rejected as the default. The case that prompted all of this needs the key present even when it is empty, because a coverage calculation over an optional key silently under-reports rather than failing. A project that genuinely wants optional says so in one word.

Two smaller decisions are worth recording because both are load-bearing in ways that read as cosmetic. The template's new lines are inserted by replacing a whole line rather than a token inside one, and the line is deleted outright when a project has declared nothing — substituting an empty string would leave a blank line behind, and the claim being made is that a project declaring nothing gets a byte-identical file. That claim is asserted against the committed artefact rather than eyeballed, and then checked a second time by running the real generator, because this project has already paid for a verification step that read a different artefact than the one that changed and passed green. And a backfilled field is written at the end of the frontmatter block rather than after the history list, because the history list is appended to every time an item moves status, and a key placed just above the last entry would be swallowed into the list on the next move. There is a test that inserts a field and then moves the item, which is the only way that particular mistake becomes visible.

One decision was forced rather than chosen. An existing test asserted that the skill source contains a literal instruction against one forbidden field, and that instruction is now assembled rather than literal, so the assertion could no longer see it. Deleting it was available and would have been a quiet loss of coverage. It was relocated instead, into the package that does the assembling, where it now checks the prohibition in both of the two possible renderings rather than once in the template. Coverage of that rule went up, not down — but the only reason anybody looked is that a test failed, which is the argument for having had it.

How AIDLC solves it

A project can now declare its own roadmap item frontmatter fields, and every framework tool that writes an item honours them.

The problem was a contradiction, not a missing feature. The portable roadmap-item skill — the one artifact AIDLC produces that is meant to leave the repository, loaded into whatever agent a contributor already uses — handed that agent a frontmatter template and one absolute rule: "Do not add any other field." Meanwhile fieldmodel-apps, the framework's first real external project, keeps requirements traceability as data: every item declares which requirement IDs it delivers in a requirements: [...] key, and a check computes coverage as a set difference, failing any item missing the key even when empty — because a set difference over an optional key silently under-reports.

The two rules were individually correct and jointly unsatisfiable. Following the skill exactly produced an item that failed the project's build; satisfying the build meant disobeying an explicit instruction in a file stamped aidlc:generated — framework-owned. Neither side could fix it: the project could not patch the template, and the framework had no way to learn that a downstream project had invented a field.

A project now declares its fields where the framework already looks:

roadmap:
  item_fields:
    - name: requirements
      required: true
      default: []

roadmap.sync already lives in that section, so this introduces no new file and no new prompt. required defaults to true — the motivating case needs the key even when empty — and default defaults to null.

Declaring a field changes four things:

  • The emitted roadmap-item skill's template carries the field, and its rule becomes "add no field beyond the template above" rather than an absolute. Each declared field is spelled out with its required-ness and default, because the agent loading that skill usually has no checkout and cannot read the config.
  • aidlc roadmap import and the roadmap-layout doctor migration — the two writers that rebuild frontmatter from a fixed key list — write the declared default, so an imported or migrated item is not born failing the project's gate.
  • aidlc doctor reports items missing a required declared field and backfills the declared default. A wrong-shaped value is reported and never rewritten.
  • Nothing else needed changing. Triage moves and the roadmap-done action already preserved extension fields verbatim, because writeHistoryEntry is a text edit rather than parse-mutate-reserialize. That was true by accident of a decision made for a different reason; it is now asserted by test, byte-for-byte.

The boundary is presence and shape, never contents. Shape comes from the declared default — a list default means a list value — rather than from a separate type: key, so there is no second representation of one fact to drift. A default: null declares no shape, so only presence is checked. There is no config key that could express a rule about what goes in a field, and that absence is the feature: requirements: [not-a-real-id] is the project's own script's business. status is refused by name with its own message, because status is the containing directory and a second representation of it is the one extension the layout can never allow.