Skip to content

fix(db): validate explicit insert nodeType against anchor's tier (#383) - #656

Merged
thewrz merged 5 commits into
mainfrom
fix/issue-383
Aug 5, 2026
Merged

fix(db): validate explicit insert nodeType against anchor's tier (#383)#656
thewrz merged 5 commits into
mainfrom
fix/issue-383

Conversation

@thewrz

@thewrz thewrz commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why

insertParagraphAfter (src/db/queries/paragraph-insert.ts) creates a new
node as a SIBLING of the anchor — same parent_id. It already rejects a part
anchor and rejects a nodeType outside the globally insertable set, but for a
non-part anchor it accepted any explicit nodeType that was insertable
somewhere, without checking it was insertable at this anchor's tier. A
caller could request pr1 after an article anchor, or article after a
pr1 anchor, and the row would land one CSI tier away from where it belongs.
The default path (no explicit nodeType) was never affected, because the
defaulted type always equals the anchor's own type — a trivially valid
sibling. The WYSIWYG Enter gesture uses the default, so no current client hit
this; it required an explicit, mismatched nodeType.

What

resolveInsertableNodeType now also requires the explicit nodeType to be a
legal sibling of the anchor via a new pure helper, isSiblingCompatible:

  1. nodeType === anchor.node_type — the general rule: a sibling's only
    proven-legal type is the type the anchor itself already demonstrates as
    legal at that parent_id.

  2. nodeType === 'continuation' — a continuation carries no CSI tier of its
    own and is legal at any tier.

  3. anchor.node_type is tierless — a note or a continuation
    KNOWN AMBIGUITY: rule 1 only constrains anything because the anchor
    demonstrates a tier that is legal at its parent. A tierless anchor
    demonstrates nothing: a note is an editorial aside, and a continuation
    continues the preceding node's text and inherits that node's tier rather
    than stating one. Neither can constrain the tier of what follows it, and
    both legitimately interleave among body paragraphs of any tier. Deliberately
    permissive, documented at the check site and pinned by regression tests at
    all three surfaces.

    The continuation half of this condition was added by owner decision
    after the Codex adversarial review flagged that the original note-only
    rule refused a legitimate insert (a pr1 after a continuation that itself
    follows a pr1 is ordinary content). The original behaviour was fail-closed,
    so it never mis-tiered a row.

Anything else reuses the existing invalid-type status, carrying the
rejected nodeType — no new status value, no response-shape change. The
pre-existing part-anchor guard (anchor.node_type !== 'part') is kept as
defense-in-depth rather than removed as "logically subsumed," since the issue
explicitly calls out not regressing that case.

The merge added-op apply path (input.explicitId set) is unaffected: its
structural-anchor guard and KNOWN AMBIGUITY (#374) still run first and are
untouched by this change.

Design decisions

  • The sibling-compatibility rule above was settled verbatim by the repository
    owner in the issue body — no ADR, per this sprint's explicit override. The
    rationale lives in isSiblingCompatible's doc comment and here.
  • The tierless-anchor permissiveness (note and continuation) is deliberate,
    not an oversight — pinned with a KNOWN AMBIGUITY comment in the source and
    in the DB/REST/MCP regression tests, per repo convention. The two types are
    named together as TIERLESS_ANCHOR_NODE_TYPES so the shared reason (neither
    carries a CSI tier of its own) is stated once rather than duplicated as two
    string literals.
  • Deriving sibling compatibility from the target parent instead was
    proposed by the adversarial review and rejected: that is the parent→child
    type table this rule avoids on purpose, and it mis-rejects ilvl-gapped legacy
    data — the CPI offset case, where a pr3 legitimately sits under an
    article.
  • The part-anchor guard is kept even though it's now logically subsumed by
    sibling-compatibility for explicit-type inputs (a part anchor can never
    equal an insertable nodeType, so it always fails the schema check too) —
    touching a guard the issue explicitly protects was unnecessary risk for no
    benefit.
  • The pre-existing "honors an explicit nodeType" DB test asserted
    pr2-after-pr1-anchor succeeding — the same cross-tier bug class named in
    the issue (pr2 nests as pr1's CHILD, never its sibling). Corrected it to
    assert an explicit continuation instead, which the rule actually permits
    at any tier, rather than leaving a test that encoded the bug.

OpenAPI / MCP lockstep

No response shape or reachable-status-set change — invalid-type is reused
verbatim, so the REST 422 shape and the MCP tool-error shape are both
unchanged. openapi.yaml's POST /specs/{id}/paragraphs description and the
insert_paragraph MCP tool description were both updated in this commit,
prose-only, to document the new sibling-tier requirement so the two surfaces
stay in lockstep with the code and with each other.

Testing

  • Unit tests pass — pnpm test: 255 files / 3630 tests passed.
  • Integration tests pass — pnpm test:integration (DATABASE_URL exported
    alone, NODE_ENV=test, never sourcing .env): 158 files passed / 12
    skipped (170), 1819 tests passed / 141 skipped (1960).
  • pnpm lint green (eslint incl. complexity/cognitive-complexity ≤10,
    tsc --noEmit, prettier --check).
  • Manual verification: read the full diff against the issue's two named
    malformed examples (pr1-after-article, article-after-pr1) and
    confirmed both are now rejected with invalid-type, and that
    article-after-article and a part anchor's rejection are both
    unchanged.
  • Mutation-verified: neutralized isSiblingCompatible (forced true),
    reran the DB/REST/MCP integration suites — exactly the 6 new regression
    tests failed (54 pass / 6 fail), no other test affected. Reverted the
    mutation — all 6 pass again alongside the rest of the suite (confirmed
    via the full pnpm test:integration run above).

Mutation transcript (independently reproduced during the review loop)

Two mutations, because the rule has two independently load-bearing halves —
the rejection it adds and the tierless exception it carves out. A single
"force true" run only proves the first.

Mutation A — gate fully neutralized (isSiblingCompatiblereturn true):

Test Files  3 failed (3)
     Tests  6 failed | 57 passed (63)

FAIL src/api/paragraph-insert.integration.test.ts > 422s a pr1 requested after an article anchor (#383)
FAIL src/api/paragraph-insert.integration.test.ts > 422s an article requested after a pr1 anchor (#383)
FAIL src/mcp/paragraph-tools.integration.test.ts  > rejects an explicit nodeType ... not a legal sibling of THIS anchor (#383)
FAIL src/db/queries/paragraph-insert.integration.test.ts > rejects a pr1 requested after an article anchor (#383)
FAIL src/db/queries/paragraph-insert.integration.test.ts > rejects an article requested after a pr1 anchor (#383)
FAIL src/db/queries/paragraph-insert.integration.test.ts > rejects a pr2 requested after a pr1 anchor (#383)

Every failure is a cross-tier rejection assertion, one per surface pair.

Mutation B — condition 3 reverted to the pre-owner-decision note-only rule
(TIERLESS_ANCHOR_NODE_TYPES.has(anchorNodeType)anchorNodeType === 'note'):

Test Files  3 failed (3)
     Tests  3 failed | 60 passed (63)

FAIL src/api/paragraph-insert.integration.test.ts > 201s an explicit pr1 after a continuation anchor (#383)
FAIL src/mcp/paragraph-tools.integration.test.ts  > accepts an explicit pr1 after a continuation anchor (#383)
FAIL src/db/queries/paragraph-insert.integration.test.ts > accepts an explicit pr1 after a continuation anchor (#383)

Exactly the three newly-permitted cases, so the continuation half of the
tierless exception is load-bearing and not decorative.

Restoring the check in both cases:

Test Files  3 passed (3)
     Tests  63 passed (63)

No unrelated test moved in either run. The gate fails when the fix is removed
and when the exception is narrowed — it is not vacuous.

Adversarial review

Codex gpt-5.6-sol @ xhigh, run once as the final draft gate: 2 [P2]
findings, both now resolved
.

  • F1 fixed (0beeb590) — the invalid-type message suggested the very
    type it had just rejected, leaving the caller no way to correct the request.
  • F2(b) fixed (59921e21) — the continuation anchor case, escalated and
    approved by the repository owner; see above.
  • F2(a) declined — the note-anchor permissiveness is the settled rule and
    pre-existing behaviour, and the proposed parent-derivation replacement is
    rejected on the CPI ilvl-gap grounds above.

Full assessment in a PR comment.

Closes #383.

🤖 Co-authored by Claude Sonnet 5.

Summary by CodeRabbit

  • Bug Fixes

    • Enforced compatible paragraph types when inserting siblings.
    • Rejected mismatched tiers with clearer error messages.
    • Allowed valid insertions after continuation and tierless note anchors.
    • Clarified that parts and notes cannot be inserted in restricted positions.
  • Documentation

    • Updated API and insertion-tool guidance with paragraph compatibility rules.

insertParagraphAfter validated an explicit nodeType against the global
insertable set, but not against the anchor it would become a sibling
of. A non-part anchor with a structurally incompatible explicit
nodeType (e.g. pr1 requested after an article anchor, or article after
a pr1 anchor) slipped through and landed a malformed row one CSI tier
away from where it belongs.

resolveInsertableNodeType now also requires the explicit nodeType to
be a legal sibling of the anchor: the anchor's own type, continuation
(legal at any tier), or any insertable type when the anchor is a note
(KNOWN AMBIGUITY — a note carries no tier of its own, documented at
the check site and pinned by a regression test). Anything else reuses
the existing invalid-type status carrying the rejected type, so the
REST/MCP response shape and status set are unchanged.

Regression tests added at the DB, REST, and MCP boundaries. The
existing "honors an explicit nodeType" DB test asserted pr2-after-pr1
(the same cross-tier bug with a different pair) succeeding; it now
asserts an explicit continuation instead, which the rule permits at
any tier.

Design decisions:
- The sibling-compatibility rule was settled by the repository owner
  in the issue body; no ADR needed per this sprint's override — the
  rationale lives in isSiblingCompatible's doc comment and here.
- openapi.yaml and the insert_paragraph MCP tool description are
  updated in the same commit to describe the sibling-tier requirement
  in prose; neither the reachable status set nor any response shape
  changed, so no contract-shape edits were needed.
- Mutation-verified: removing the isSiblingCompatible check from
  resolveInsertableNodeType fails exactly the 6 new regression tests
  (54 pass, 6 fail) across the DB/REST/MCP integration suites; with
  the check restored all 60 pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f580a045-0518-4063-b6a0-54cddecc4850

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Paragraph insertion now validates explicit nodeType values against the anchor’s sibling tier. The database, REST API, OpenAPI contract, and MCP tool document and test matching tiers, continuations, tierless anchors, and rejected cross-tier insertions.

Changes

Paragraph sibling compatibility

Layer / File(s) Summary
Database sibling validation
src/db/queries/paragraph-insert.ts, src/db/queries/paragraph-insert.integration.test.ts
insertParagraphAfter validates explicit node types against the anchor tier. Matching types and permitted tierless-anchor cases succeed. Cross-tier types return invalid-type.
REST API contract and validation
openapi.yaml, src/api/paragraphs.ts, src/api/paragraph-insert.integration.test.ts
The API contract and error response describe sibling compatibility. Integration tests cover rejected cross-tier insertions and accepted continuation cases.
MCP parity and validation
src/mcp/paragraph-tools.ts, src/mcp/paragraph-handlers.ts, src/mcp/paragraph-tools.integration.test.ts, src/mcp/paragraph-handlers.test.ts
The MCP tool and handler expose the same compatibility rules. Tests cover invalid-type handling and valid tierless-anchor insertions.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant REST_or_MCP
  participant insertParagraphAfter
  participant Database
  Caller->>REST_or_MCP: Request insertion with nodeType
  REST_or_MCP->>insertParagraphAfter: Insert paragraph after anchor
  insertParagraphAfter->>Database: Validate sibling compatibility
  Database-->>insertParagraphAfter: Insert sibling or return invalid-type
  insertParagraphAfter-->>REST_or_MCP: Return result
  REST_or_MCP-->>Caller: Return created paragraph or error
Loading

Possibly related PRs

  • wrzonance/SpecR#370: Introduced the paragraph insertion endpoint and related insertion logic.
  • wrzonance/SpecR#376: Extended the paragraph insertion implementation addressed by this change.
  • wrzonance/SpecR#463: Also modifies paragraph insertion compatibility logic and integration tests.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR meets the validation, parity, and regression-test objectives, but it omits the required ADR for hierarchy rules and ambiguity handling. Add an ADR that defines legal sibling rules for tiers, continuations, notes, and other ambiguous cases.
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies validation of explicit insert node types against the anchor tier.
Out of Scope Changes check ✅ Passed All code, documentation, and regression-test changes support issue #383; no unrelated changes are identified.

Comment @coderabbitai help to get the list of available commands.

The sibling-compatibility check made `invalid-type` newly reachable for a
type that IS on the insertable list but is wrong for the anchor's tier. The
existing message ended "pass nodeType (article, pr1–pr7, or continuation)",
so a rejected `pr1` was answered by suggesting `pr1` — the caller was told to
resend exactly what had just been refused, with no way to correct it.

Reword both surfaces in lockstep to state the actual rule (match the anchor's
own type, or continuation; parts and notes are never insertable). Message-only:
no new status value, no response-shape change, so the REST switch and the MCP
handler stay exhaustive and openapi.yaml's schema is untouched.

Found by the end-of-draft Codex (gpt-5.6-sol, xhigh) adversarial review.
Pinned by an assertion that the 422 no longer offers the rejected type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thewrz

thewrz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

This was written agentically; verify its assertions and edit accordingly:

Adversarial cross-review — Codex gpt-5.6-sol, effort xhigh

Ran once as the final draft gate (CI green, no conflicts). Transcript: 237 KB, session 019fcf15-80ec-7800-b2b4-7ff2a974aca5. 2 findings, both [P2] — the earlier "zero findings" report for this branch was inaccurate; the review had genuinely not produced a verdict at that point.

F1 — "Report the tier mismatch instead of the global type list" — VALID, FIXED in 0beeb590

The sibling-compatibility check makes invalid-type newly reachable for a type that is on the insertable list but is wrong for this anchor's tier. The message ended pass nodeType (article, pr1–pr7, or continuation) — so a rejected pr1 was answered by suggesting pr1. The caller was told to resend exactly what had just been refused, with no way to correct the request.

Reworded both surfaces in lockstep to state the actual rule. Message-only: no new status value, no response-shape change, openapi.yaml's schema untouched, REST switch and MCP handler still exhaustive. Pinned by an assertion that the 422 no longer offers the rejected type back.

F2 — "Validate non-structural anchors using the target parent" — SPLIT: (a) DECLINED, (b) FIXED in 59921e21

(a) The note-anchor permissiveness — DECLINED, unchanged. Correct as an observation, declined as a change. This is the rule the repository owner settled: a note carries no CSI tier, so it cannot constrain its siblings' tier — permit any insertable type, which is also the pre-existing behaviour. Not a regression this PR introduces; deliberately preserved and pinned as a KNOWN AMBIGUITY. Codex's proposed remedy — deriving compatibility from the target parent — remains rejected: that is exactly the parent→child type table the rule avoids on purpose, and it mis-rejects ilvl-gapped legacy data (the CPI offset case, where a pr3 legitimately sits under an article).

(b) The continuation-anchor case — FIXED. This was escalated rather than decided in-branch, because permitting it meant adding to a rule the owner had specified as if and only if. The repository owner reviewed it and approved the extension, so condition 3 now reads the anchor's node_type is note or continuation.

The rationale is the one already written for the note branch, now generalised: rule 1 only constrains anything because the anchor demonstrates a tier legal at its parent. A continuation demonstrates nothing — it continues the preceding node's text and inherits that node's tier rather than stating one — so like a note it cannot constrain what follows it. A pr1 after a continuation that itself follows a pr1 is ordinary, well-formed content, and the previous rule refused it. The old behaviour was fail-closed, so no row was ever mis-tiered by it.

Implemented as a named TIERLESS_ANCHOR_NODE_TYPES set rather than a second string literal, with the KNOWN AMBIGUITY comment extended to say why both types qualify. New regression tests at all three surfaces (DB, REST, MCP): continuation anchor + explicit pr1 now succeeds. Every existing pin re-run and intact — article-after-article accepted, part anchor rejected, pr1-after-article and article-after-pr1 still rejected.

The F1 message was re-checked against the widened rule and remains accurate: a tierless anchor can no longer produce a tier mismatch at all, so every caller who can still see that message has a tier-bearing anchor, for which "must match the anchor's own type or be continuation" is exactly right.

Gate is mutation-verified (re-run after the owner's change; two mutations)

The rule has two independently load-bearing halves — the rejection it adds and the tierless exception it carves out — so one "force true" run is not sufficient evidence.

A — gate fully neutralized (isSiblingCompatiblereturn true): 57 passed / 6 failed. The 6 are exactly the cross-tier rejection tests, one per surface pair (DB, REST, MCP).

B — condition 3 narrowed back to the pre-decision note-only rule: 60 passed / 3 failed. The 3 are exactly the newly-permitted continuation-anchor acceptance tests, one per surface — so the continuation half is load-bearing, not decorative.

Restoring the check in both cases → 63/63 green. No unrelated test moved in either run.

Also verified the merge added-op path is untouched: applyAddedChange (src/merge/conflict.ts:286) passes no nodeType, so the type defaults to the anchor's own and satisfies rule 1 trivially; its structural-anchor guard still runs first.

🤖 Co-authored by Claude Opus 5.

#383)

Owner decision extending the sibling-compatibility rule. Condition 3 now reads
"the anchor's node_type is note OR continuation" — the other two conditions are
unchanged.

The rule's general case works because the anchor DEMONSTRATES a tier that is
legal at its parent. A continuation demonstrates nothing: it continues the
preceding node's text and inherits that node's tier rather than stating one of
its own, so — exactly like a note — it cannot constrain the tier of what
follows it. A pr1 after a continuation that itself follows a pr1 is ordinary,
well-formed content, and the previous note-only rule refused it. The behaviour
was fail-closed, so no row was ever mis-tiered by it.

Names the concept as TIERLESS_ANCHOR_NODE_TYPES rather than a second literal,
and extends the KNOWN AMBIGUITY comment to say WHY both types qualify. Keeps
rejecting the parent-derived alternative: that is the parent->child table this
rule avoids on purpose, which mis-rejects ilvl-gapped legacy data (the CPI
offset case, where a pr3 legitimately sits under an article).

Regression tests added at all three surfaces (DB, REST, MCP): continuation
anchor + explicit pr1 now succeeds. Every existing pin re-run and intact —
article-after-article accepted, part anchor rejected, pr1-after-article and
article-after-pr1 still rejected.

Surfaced by the Codex adversarial review as F2(b), escalated, and approved by
the repository owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thewrz
thewrz marked this pull request as ready for review August 5, 2026 07:53
@thewrz

thewrz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/paragraphs.ts`:
- Line 56: Update the insertion error messages in src/api/paragraphs.ts at lines
56-56 and src/mcp/paragraph-handlers.ts at lines 106-106 to state that any
insertable node type is valid after a note or continuation anchor, while
preserving the existing matching-type rule for other anchors and excluding parts
and notes as insertable types.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb23b7e8-11c8-4b8e-ba28-c36805911395

📥 Commits

Reviewing files that changed from the base of the PR and between 14f06e3 and 59921e2.

📒 Files selected for processing (9)
  • openapi.yaml
  • src/api/paragraph-insert.integration.test.ts
  • src/api/paragraphs.ts
  • src/db/queries/paragraph-insert.integration.test.ts
  • src/db/queries/paragraph-insert.ts
  • src/mcp/paragraph-handlers.test.ts
  • src/mcp/paragraph-handlers.ts
  • src/mcp/paragraph-tools.integration.test.ts
  • src/mcp/paragraph-tools.ts

Comment thread src/api/paragraphs.ts Outdated
thewrz and others added 2 commits August 5, 2026 01:01
…jection

Both the REST route and the MCP handler hand-copied an 'invalid-type'
message naming only two of the three ways to pass isSiblingCompatible:
"nodeType must match the anchor's own type or be continuation". It omitted
the tierless-anchor exception, so an editor inserting a legal pr1 after a
`note` anchor read that their request broke a rule it does not break.

openapi.yaml and the insert_paragraph tool description already stated the
complete rule — only the message a caller actually reads was wrong.

Rather than correct two copies and leave the drift class open, the text now
lives beside the rule it describes (invalidInsertTypeMessage, next to
isSiblingCompatible in paragraph-insert.ts) and is re-exported through
db/index.ts. api/ and mcp/ cannot import each other (module-boundaries.md),
so a shared owner in db/ is the only single definition available — the same
pattern lockedObjectMessage already uses at these exact call sites.

Pins the message states the complete rule, matching on the concept
(/tierless/i) not the prose. Behaviour after a tierless anchor was already
covered at all three layers; the message half was not, which is how it
drifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The db/index.js mock stubbed lockedObjectMessage by hand, so adding
invalidInsertTypeMessage to the real barrel left the mock without it — the
handler threw a TypeError that its own catch flattened into "Internal
error", which is how the suite caught this.

Fixed by importing the REAL builder into the mock factory (the pattern the
error classes at the top of the same factory already use) rather than
writing a second stub. A stub would let the two wordings drift and leave
this test asserting against fiction. Verified the dynamic import survives
paragraph-insert.ts's own '../index.js' import without recursing.

That in turn makes the MCP surface pinnable, so it now asserts the complete
rule the same way the REST integration test does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thewrz
thewrz merged commit d0739b8 into main Aug 5, 2026
11 checks passed
@thewrz
thewrz deleted the fix/issue-383 branch August 5, 2026 15:13
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.

insertParagraphAfter: validate explicit nodeType against the anchor's level (cross-level inserts still slip through)

1 participant