feat: ideate — the idea-admission protocol (itd-104) - #157
Merged
Conversation
The 2026-07-27 grill settled the design; the spec records the settlements plus the mechanism (three host-delegated legs, binary validates cited record ids and writes the dated research verdict, survivors graduate through the existing intent create). Freshly minted id collided again (iss-80 class): renumbered to spc-18. Assisted-by: Claude:claude-fable-5
The lifeboat synthesis seam and the release ingest seam each carried their own copy of the same primitive — neutralise line breaks and HTML comment markers, sanitise, normalise whitespace, cap — and ingest.go's own comment asked that a third caller promote it rather than write it again. The ideate ingest seam is that third caller. termsafe is the home because the cleaner IS Sanitize plus the two neutralisations a file writer always makes and a terminal sanitiser has no reason to. Two forms are kept because the two existing callers legitimately differ: CleanProse trims, CleanProseLine collapses runs to one space for prose landing in a line-structured file. Both callers keep their exact prior behaviour. Assisted-by: Claude:claude-opus-5[1m]
An ingest boundary that accepts a citation has to answer "does this id name a real record", and nothing in the tree answered it. recordid already owns the id space and the filename grammar for the minting side, so the read side belongs beside it rather than next to whichever consumer needs it first. Only the four id-bearing families resolve (itd/spc/iss/adr); the brief and the principles carry no per-entry id, so a reference to one is prose, never a citation. The scan is shallow (family root plus one bucket level), budgeted, and symlink-refusing, so a planted link cannot make a record appear to exist and a pathological tree cannot dominate an ingest. An absent family is not an error; an unreadable present one is, because reporting "no such record" for a store that could not be read would refuse a correct citation. Assisted-by: Claude:claude-opus-5[1m]
itd-104's core: the deterministic frame around the three-leg gauntlet. The host runs the judgement — primary-source research, a grill against the existing record, an independent adversarial review — and this validates what it produced and writes the durable verdict. It never fetches a source and never calls a model. Four gates carry the acceptance criteria. The legs are an ordered array, not three named fields, precisely so "three legs, in order" is a property of the document the binary can check rather than one it assumes. Every grill hit is cited by record id and every id must resolve in this repository; an id naming no record refuses the whole verdict and names the id. The verdict is a closed enum. And an empty rejected-alternatives list is admissible only behind an explicit marker — silence by omission is the exact failure the record exists to prevent. Refusals are whole-document, never cite-or-be-dropped: a verdict record with a quietly-dropped falsified claim is worse than no record, because a later session trusts it. Every refusal returns before the first write; the one write that can strand a record (the decision-log pointer failing after the record lands) rolls the record back. Write containment is two-layer, per the itd-101 lesson: the slug grammar admits no separator or dot segment before any path is built, and os.Root guards the leaf lookup. A mutation run with the lexical layer removed confirms os.Root alone still refuses a traversal slug. Assisted-by: Claude:claude-opus-5[1m]
The `ideate record` verb is the front door onto internal/core/ideate: --verdict-json (file or stdin) behind the shared payload guards, one core call, exit 0 recorded / exit 2 refused with nothing written. --verdict-json is required rather than optional-with-a-fallback: the disembark synthesis verbs can fall back because a packed lifeboat's own files carry evidence, and there is no evidence-only verdict an idea could have — a binary that invented one would be doing the judging. commands/abcd/ideate.md carries the orchestration the binary deliberately cannot: the three legs in order, and the conduct that makes leg 3 worth running. It instructs the host to strip every authorship signal and all prior-leg framing before handing the artefact to the adversary, because leaking authorship converts an adversary into a colleague and that debiasing is the whole point of the leg. The routing nudge lands in the two places a user is already deciding where an idea goes: the bare `abcd intent` / `abcd capture` renders, and the "which ledger?" paragraph of both command pages. It is a pointer and the wording is load-bearing — no "should", no "first", no warning when it is skipped. A surface test asserts both renders name ideate and that neither presents it as a gate. Brief surface page 17 plus its registry row keep surface_coverage honest; the CLI reference and the surface baseline are regenerated. Assisted-by: Claude:claude-opus-5[1m]
The shared cleaner broke apart HTML comment markers but left a bare tag intact. In CommonMark a `<` followed by a letter, `/`, `!`, or `?` opens an HTML BLOCK, and several of those block types run to the end of the document if their closing condition never arrives — so one `<script>` in a model-supplied field made every later section of the record render as inert text inside an unclosed element, while a forged section above it rendered normally. An artefact whose whole value is that a later session trusts it must not be able to hide its own evidence. Fixed in the one canonical primitive, so it lands at all three trust boundaries at once: the release changelog ingest, the lifeboat synthesis writers, and the ideate verdict. Inserting a space after the `<` is enough — CommonMark needs the name, `/`, `!`, or `?` to follow immediately — and it subsumes the comment rule, which produced the same `< !--` output. The neutralisation now runs AFTER terminal sanitisation, which closes a second hole the reordering revealed: Sanitize SUBSTITUTES a '?' for a masked rune rather than deleting it, so `<` before an escape byte became `<?` — a processing instruction — under the old order. A regression case pins exactly that. Reported by the security review of this branch. Assisted-by: Claude:claude-opus-5[1m]
The write guarded only the LEAF: fsutil.IsRealDir lstats the full path, so the kernel resolves every ancestor before the check sees them, and an os.Root opened at an already-escaped directory constrains nothing. A repository whose `.abcd/development` is a symlink therefore wrote the verdict record outside the tree while the CLI reported a repo-relative path. One os.Root is now opened at the REPOSITORY root and every path this verb touches goes through it. os.Root refuses symlink traversal at every component, so the escape is refused where it is attempted rather than after it succeeds. Two changes ride with it, both from the same lines: The research directory is created when absent instead of demanded. Nothing in abcd establishes it and no convention check requires it, so refusing failed the first run in every repository — after the three host legs had been paid for, and with the verdict unrecoverable when it arrived on stdin. The record is written with an EXCLUSIVE create through the Root (fsutil.CreateExclusiveIn, the new canonical primitive for a contained never-replace write) rather than a check followed by an atomic replace. The exclusive create IS the no-overwrite guarantee, in one syscall, so two concurrent runs of one slug cannot both pass a check. The decision log's read-modify-write now runs under the house advisory lock, so a concurrent run cannot erase the other's pointer and strand a record nothing points at. Reported by the security and ruthless reviews of this branch. Assisted-by: Claude:claude-opus-5[1m]
Two more ways the verdict record's own structure was reachable from prose. cell() escaped the pipe but not the backslash. GFM reads `\|` inside a cell as an escaped delimiter, so a claim containing `\|` became `\\|` — an escaped backslash followed by a LIVE delimiter. That splits the row and pushes the core-owned Finding column off the end, where GFM silently drops it, so the record displays whatever word the prose put in third position instead of the status the payload declared: a falsified claim rendering as verified. One replacer now escapes both in a single pass. blockText() did not escape a leading `[`. A paragraph shaped like a link reference definition is CONSUMED by CommonMark and renders as nothing, so an idea in that shape erased the record's own subject while the verdict and the three legs still read normally. Both are pinned by regression tests that fail against the previous commit, the table one parsing the rendered row the way GFM does rather than counting pipes — counting cannot see this class at all. Three review nits ride along: CreateExclusiveIn fsyncs the parent so the record's NAME is as durable as its content (otherwise a crash could leave the decision-log pointer with no directory entry — a pointer to nothing, the inverse of what the rollback prevents); the one-canonical-primitive detector learns the new primitive; and the prose cleaner's comment now states honestly that it fires mid-line, and what that costs. Reported by the post-fix security and ruthless reviews of this branch. Assisted-by: Claude:claude-opus-5[1m]
# Conflicts: # .abcd/development/brief/04-surfaces/README.md # .abcd/work/DECISIONS.md
REPPL
enabled auto-merge
July 28, 2026 07:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements itd-104 (idea-admission protocol) per spc-18 — the spec derived from the 2026-07-27 Grill Settlements.
/abcd:ideateruns three host-delegated legs — primary-source research, a record grill, an adversarial review — and the binary is the deterministic frame that validates the verdict and writes the record.What this delivers
internal/core/ideate/—abcd ideate record <slug> --verdict-json: validates the verdict document (three legs as an ordered array so leg order is machine-checked; verdict ∈ survives/killed/reframed; rejected alternatives must be explicit, an empty list needs a declared marker), resolves every record id cited by grill hits against the live record viarecordid.Resolver(an unresolvable id refuses the whole verdict, naming it), and writes the dated research record plus the DECISIONS.md pointer line.commands/abcd/ideate.md(the skill page orchestrating the three legs, with the adversarial leg handed the idea fresh-context, off-policy, as an artefact of unknown authorship), routing nudges in the intent and capture surfaces (a test asserts they are pointers — no "must"/"required"/blocking language), brief surface page + registry row, regenerated CLI reference, CHANGELOG.termsafe.CleanProse(the third caller its comment asked for), lifeboat and release routed through it behaviour-preservingly.Settlements honoured
Reviews
\|pipe-shift that could render a falsified claim as verified and a link-reference erasure. All fixed; final APPROVE verified against the production binary plus a 441-case prefix scan and 400 randomised claims.os.Rootcontainment still refused every traversal.Known, deliberately not done
\#in author prose loses its backslash in the rendered record — fidelity-only, no attack path; left un-gold-plated.Gates:
make preflight, docs lint and record-lint all clean (0 blockers).