Skip to content

feat: ideate — the idea-admission protocol (itd-104) - #157

Merged
REPPL merged 9 commits into
mainfrom
feat/itd-104-ideate
Jul 28, 2026
Merged

feat: ideate — the idea-admission protocol (itd-104)#157
REPPL merged 9 commits into
mainfrom
feat/itd-104-ideate

Conversation

@REPPL

@REPPL REPPL commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Implements itd-104 (idea-admission protocol) per spc-18 — the spec derived from the 2026-07-27 Grill Settlements. /abcd:ideate runs 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 via recordid.Resolver (an unresolvable id refuses the whole verdict, naming it), and writes the dated research record plus the DECISIONS.md pointer line.
  • Wiring on both planes: the CLI verb, 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.
  • One reuse refactor in its own commit: the untrusted-prose cleaner promoted to termsafe.CleanProse (the third caller its comment asked for), lifeboat and release routed through it behaviour-preservingly.

Settlements honoured

  • Optional verb, never a gate — capture stays one line; the nudge is help text only (AC 1, pinned by test).
  • Evaluator outside the loop — the adversarial leg is fresh-context and receives unknown-authorship artefacts (AC 2).
  • Recorded either way — survives, killed, and reframed all write the record with rejected alternatives; only survivors graduate, through the existing intent create (AC 3).
  • Check the record first — grill hits are cited by id and validated at ingest (AC 4).

Reviews

  • Security (untrusted JSON ingest + derived file writes): initial BLOCK twice — raw-HTML block swallow of the rendered record, symlinked-ancestor containment escape (demonstrated writing outside the repo), then a GFM \| 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.
  • Ruthless: FIX-FIRST (absent research dir refused the first real run) fixed; final SHIP with all NITs applied.
  • Slug and citation gates are mutation-checked: each was disabled in place, caught by its test, restored. With the lexical slug layer off, os.Root containment still refused every traversal.

Known, deliberately not done

  • A leading \# in author prose loses its backslash in the rendered record — fidelity-only, no attack path; left un-gold-plated.
  • The three legs have no dedicated agent-definition files; briefs are inline in the skill page, matching the existing command-page convention.

Gates: make preflight, docs lint and record-lint all clean (0 blockers).

REPPL added 9 commits July 28, 2026 00:47
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
REPPL enabled auto-merge July 28, 2026 07:29
@REPPL
REPPL merged commit 3bd32f7 into main Jul 28, 2026
12 checks passed
@REPPL
REPPL deleted the feat/itd-104-ideate branch July 28, 2026 07:31
@REPPL REPPL mentioned this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant