[mine-blindspots hyperthrive corpus] TrailingProhibition never scans markdown table cells #159

Closed
opened 2026-07-27 15:07:03 +00:00 by jared · 3 comments
Owner

Context

Cross-project filing from a mine-blindspots run against the hyperthrive-websites repo as external corpus. Bucket (c): scanning-surface gap — the cop's logic is fine, but a whole document region (markdown tables) never reaches it.

Problem

hyperthrive-websites/context/stack/decisions.md:18 (a markdown table "Why" cell):

| **Sending volume** | 9 mailboxes, deliberate drip ramp — opens at 5/day/box for roughly the first 1–2 weeks of the first campaign, then increases as boxes finish warming and based on reply feedback | Deliverability physics, not ambition. 5/day is an opening ramp cadence, not a permanent cap — never raise it recklessly, but it is expected to rise. |

The trailing clause "— never raise it recklessly, but it is expected to rise" sits inside a table cell and is never examined by TrailingProhibition because table rows never become ListItems::Items.

Detection

plugins/os-aidd-lint/lib/aidd_lint/list_items.rb:7:

MARKER = /\A(\s*)([-*+]|\d+\.)\s+(.+?)\s*\z/

This only matches bullet/numbered-list lines. A markdown table row (| cell | cell | cell |) never matches MARKER, so ListItems.build never yields an Item for it, and TrailingProhibition#investigate (which only calls ListItems.build, per trailing_prohibition.rb:34) never sees table-cell text at all — regardless of whether the clause-splitting logic inside would otherwise catch it.

Correction

Add a table-cell scanner (parse | ... | ... | rows, split into cells) that feeds the same clause-check logic (trailing_strong_clause/trailing_parenthetical_not) TrailingProhibition already uses for list items, rather than requiring table content to first become a ListItems::Item.

Pass/fail examples

  • Must fail (flag): a table cell containing Deliverability physics, not ambition. 5/day is an opening ramp cadence, not a permanent cap — never raise it recklessly, but it is expected to rise. (the trailing "— never raise it recklessly" clause).
  • Must pass (no flag): the same clause rendered as a bullet, e.g. - Deliverability physics, not ambition — never raise it recklessly. if it already leads a bullet with "never" via LEADS_WITH_PROHIBITION, or a table cell with no prohibition language at all.

Provenance

/os-aidd-lint:mine-blindspots run, external corpus hyperthrive-websites, bucket (c), cop citation list_items.rb:7 (MARKER), evidence hyperthrive-websites/context/stack/decisions.md:18. Verified independently by a second agent pass against the live cop source and the live corpus file. 2026-07-27.

Fix direction

Add a table-cell scanner feeding the same clause logic TrailingProhibition already has, so table content is not structurally invisible to the cop.


Discoverer: hyperthrive-websites, session id unavailable, 2026-07-27. Filed from a mine-blindspots run against this repo as external corpus.

## Context Cross-project filing from a `mine-blindspots` run against the `hyperthrive-websites` repo as external corpus. Bucket (c): scanning-surface gap — the cop's logic is fine, but a whole document region (markdown tables) never reaches it. ### Problem `hyperthrive-websites/context/stack/decisions.md:18` (a markdown table "Why" cell): ``` | **Sending volume** | 9 mailboxes, deliberate drip ramp — opens at 5/day/box for roughly the first 1–2 weeks of the first campaign, then increases as boxes finish warming and based on reply feedback | Deliverability physics, not ambition. 5/day is an opening ramp cadence, not a permanent cap — never raise it recklessly, but it is expected to rise. | ``` The trailing clause "— never raise it recklessly, but it is expected to rise" sits inside a table cell and is never examined by `TrailingProhibition` because table rows never become `ListItems::Item`s. ### Detection `plugins/os-aidd-lint/lib/aidd_lint/list_items.rb:7`: ```ruby MARKER = /\A(\s*)([-*+]|\d+\.)\s+(.+?)\s*\z/ ``` This only matches bullet/numbered-list lines. A markdown table row (`| cell | cell | cell |`) never matches `MARKER`, so `ListItems.build` never yields an `Item` for it, and `TrailingProhibition#investigate` (which only calls `ListItems.build`, per `trailing_prohibition.rb:34`) never sees table-cell text at all — regardless of whether the clause-splitting logic inside would otherwise catch it. ### Correction Add a table-cell scanner (parse `| ... | ... |` rows, split into cells) that feeds the same clause-check logic (`trailing_strong_clause`/`trailing_parenthetical_not`) `TrailingProhibition` already uses for list items, rather than requiring table content to first become a `ListItems::Item`. ### Pass/fail examples - **Must fail (flag):** a table cell containing `Deliverability physics, not ambition. 5/day is an opening ramp cadence, not a permanent cap — never raise it recklessly, but it is expected to rise.` (the trailing "— never raise it recklessly" clause). - **Must pass (no flag):** the same clause rendered as a bullet, e.g. `- Deliverability physics, not ambition — never raise it recklessly.` if it already leads a bullet with "never" via `LEADS_WITH_PROHIBITION`, or a table cell with no prohibition language at all. ### Provenance `/os-aidd-lint:mine-blindspots` run, external corpus `hyperthrive-websites`, bucket (c), cop citation `list_items.rb:7` (`MARKER`), evidence `hyperthrive-websites/context/stack/decisions.md:18`. Verified independently by a second agent pass against the live cop source and the live corpus file. 2026-07-27. ### Fix direction Add a table-cell scanner feeding the same clause logic `TrailingProhibition` already has, so table content is not structurally invisible to the cop. -------- **Discoverer:** hyperthrive-websites, session id unavailable, 2026-07-27. Filed from a mine-blindspots run against this repo as external corpus.
Author
Owner

Classified DECISION-NEEDED — not implemented. The scanning-surface gap is real and confirmed; what is not settled is whether this cop's advice is coherent inside a table cell.

Headline

Table scanning is cheap to build and finds 5 new clauses in this repo — but 3 to 4 of them are cells where the prohibition is already leading its own sentence, and the cop's fix instruction ("promote it to its own bullet") is impossible to follow inside a table. Decide what the cop should say about a table cell before wiring the scanner.

What I measured

I prototyped a table-cell scanner (parse pipe rows, skip |---|---| separators, honour the fence mask, feed cells to the existing trailing_strong_clause / trailing_bare_not / trailing_parenthetical_not logic — no change to ListItems::MARKER) and ran it over the 99 files in this repo's lint scope. 5 new offenses, verbatim:

File:line Clause newly flagged Verdict
os-doc-hygiene/skills/CONTEXT.md:15 it never authors hashes, token counts, safety tiers, or reversibility. true positive
os-doc-hygiene/skills/CONTEXT.md:17 Never writes files or calls git shape mismatch
check/workflows/classify-candidates.md:126 do NOT supply \git_state` or `safety_tier`` borderline
check/workflows/classify-candidates.md:148 (not a clean delete) borderline
clean/SKILL.md:688 do NOT mark \extraction_complete`` borderline

Row 2 is the instructive one. That cell reads roughly Judge only. Never writes files or calls git — the prohibition already leads its own sentence, which is precisely the shape the cop exists to produce. It is flagged only because a table cell has no notion of "leading the bullet": leads_with_prohibition? tests the start of the cell, and a cell routinely packs two or three sentences into one column because tables compress. In a bullet list, "clause 2 of 3" means buried. In a table cell, it often just means "second sentence".

Options

Option A — scan cells, reuse the message as-is.

  • Before: table content is structurally invisible; decisions.md:18's — never raise it recklessly is missed.
  • After: caught, plus the 4 above, each told "promote it to its own bullet leading with Never/Only, with the rationale as a sub-bullet." Inside a |---|---| row that instruction cannot be followed. The author's only compliant move is to destroy the table.

Option B — scan cells, but gate on the prohibition NOT leading any sentence in the cell, and give tables their own message.

  • Never writes files or calls git → suppressed (it leads a sentence).
  • — never raise it recklessly, but it is expected to rise → still caught (mid-sentence, after a dash).
  • Message becomes something like "prohibition buried mid-cell — lead the cell with it, or move the rule out of the table."
  • Cost: a second message path and a second exemption rule in a cop that currently has one of each.

Option C — no change. Tables stay invisible. Cost: a locked-decision table (exactly the decisions.md case that filed this) is a high-value place to hide a prohibition, and it is unlinted.

Recommendation

Option B, confidence 65%.

Single deciding reason: the scanner is the easy half and Option A gets it wrong in a way that is worse than the miss — a lint that issues an unfollowable instruction trains authors to ignore the cop. Confidence is only 65% because Option B's per-sentence exemption is a new heuristic with no corpus behind it beyond these 5 rows; a larger sample might move me to C.

This is genuinely your call, because it is a scope question, not a bug: is TrailingProhibition a list-item cop (its message, its exemption logic, and its docstring all assume bullets) or a document cop? Answering that decides #159 and settles how the next surface-gap ticket gets triaged.

Default if nothing is said

Nothing ships for this issue. Tables remain unscanned. Issues #158 and #161 are already implemented and labelled review on branch aidd-lint-trailing-prohibition-gaps and are independent of this decision — the prototype scanner above was measured but not committed.

Classified **DECISION-NEEDED** — not implemented. The scanning-surface gap is real and confirmed; what is not settled is whether this cop's *advice* is coherent inside a table cell. ## Headline Table scanning is cheap to build and finds 5 new clauses in this repo — but 3 to 4 of them are cells where the prohibition is already leading its own sentence, and the cop's fix instruction ("promote it to its own bullet") is impossible to follow inside a table. Decide what the cop should *say* about a table cell before wiring the scanner. ## What I measured I prototyped a table-cell scanner (parse pipe rows, skip `|---|---|` separators, honour the fence mask, feed cells to the existing `trailing_strong_clause` / `trailing_bare_not` / `trailing_parenthetical_not` logic — no change to `ListItems::MARKER`) and ran it over the 99 files in this repo's lint scope. **5 new offenses**, verbatim: | File:line | Clause newly flagged | Verdict | |---|---|---| | os-doc-hygiene/skills/CONTEXT.md:15 | `it never authors hashes, token counts, safety tiers, or reversibility.` | true positive | | os-doc-hygiene/skills/CONTEXT.md:17 | `Never writes files or calls git` | **shape mismatch** | | check/workflows/classify-candidates.md:126 | `do NOT supply \`git_state\` or \`safety_tier\`` | borderline | | check/workflows/classify-candidates.md:148 | `(not a clean delete)` | borderline | | clean/SKILL.md:688 | `do NOT mark \`extraction_complete\`` | borderline | Row 2 is the instructive one. That cell reads roughly `Judge only. Never writes files or calls git` — the prohibition **already leads its own sentence**, which is precisely the shape the cop exists to produce. It is flagged only because a table cell has no notion of "leading the bullet": `leads_with_prohibition?` tests the start of the *cell*, and a cell routinely packs two or three sentences into one column because tables compress. In a bullet list, "clause 2 of 3" means buried. In a table cell, it often just means "second sentence". ## Options **Option A — scan cells, reuse the message as-is.** - Before: table content is structurally invisible; `decisions.md:18`'s `— never raise it recklessly` is missed. - After: caught, plus the 4 above, each told *"promote it to its own bullet leading with Never/Only, with the rationale as a sub-bullet."* Inside a `|---|---|` row that instruction cannot be followed. The author's only compliant move is to destroy the table. **Option B — scan cells, but gate on the prohibition NOT leading any sentence in the cell, and give tables their own message.** - `Never writes files or calls git` → suppressed (it leads a sentence). - `— never raise it recklessly, but it is expected to rise` → still caught (mid-sentence, after a dash). - Message becomes something like *"prohibition buried mid-cell — lead the cell with it, or move the rule out of the table."* - Cost: a second message path and a second exemption rule in a cop that currently has one of each. **Option C — no change.** Tables stay invisible. Cost: a locked-decision table (exactly the `decisions.md` case that filed this) is a high-value place to hide a prohibition, and it is unlinted. ## Recommendation **Option B**, confidence **65%**. **Single deciding reason:** the scanner is the easy half and Option A gets it wrong in a way that is worse than the miss — a lint that issues an unfollowable instruction trains authors to ignore the cop. Confidence is only 65% because Option B's per-sentence exemption is a new heuristic with no corpus behind it beyond these 5 rows; a larger sample might move me to C. **This is genuinely your call**, because it is a scope question, not a bug: is TrailingProhibition a *list-item* cop (its message, its exemption logic, and its docstring all assume bullets) or a *document* cop? Answering that decides #159 and settles how the next surface-gap ticket gets triaged. ## Default if nothing is said Nothing ships for this issue. Tables remain unscanned. Issues #158 and #161 are already implemented and labelled `review` on branch `aidd-lint-trailing-prohibition-gaps` and are independent of this decision — the prototype scanner above was measured but not committed.
Author
Owner

Option B implemented per Jared's decision. Branch aidd-lint-trailing-prohibition-gaps, commit 1d29fb8. TDD.

What shipped

New AiddLint::TableCells (lib/aidd_lint/table_cells.rb) plus a check_cell path on the cop.

Rather than write a new row parser, it builds on the existing TableMask, which was already in the codebase and already requires a header row followed by a separator row. That means a stray pipe line in prose is not mistaken for a table — pinned by test_ignores_a_pipe_row_that_is_not_part_of_a_real_table. ListItems::MARKER is untouched, so no other cop's scanning surface moved.

Three cell-specific rules, each pinned by a test:

  1. Exemption is sentence-level, not clause-level. A cell is exempt when any sentence in it leads with the prohibition. This is the crux of Option B — a clause-level gate would have suppressed the ticket's own target (— never raise it recklessly is a clause that leads with never), while a sentence-level gate keeps it and still exempts ... Never writes files or calls git; never re-reads disk.
  2. Parenthetical-"not" is not carried over to cells. Cells are dense with definitional asides — (not a clean delete) in a category table is a clarification, not a rule.
  3. Cells get their own message: prohibition buried mid-cell ('...') -- lead the cell with it, or move the rule out of the table. A test asserts the bullet-shaped message does not appear on a cell offense.

Tests

296 runs, 718 assertions, 0 failures, 0 errors, 0 skips

Six new tests on this issue: buried mid-cell (the ticket's must-fail), sentence-leads exemption, table-shaped message, parenthetical exemption, separator/clean-cell quiet, non-table pipe row quiet.

Corpus delta

Measured properly this time — baseline run in a detached git worktree at the parent commit, same 99 files, so nothing else in the tree could contaminate it.

25 → 28. +3 offenses, 0 regressions, 0 removals. All three are cell offenses and all three are genuine:

os-doc-hygiene/skills/CONTEXT.md:15   it never authors hashes, token counts, safety tiers, or reversibility
check/workflows/classify-candidates.md:126   do NOT supply `git_state` or `safety_tier`
clean/SKILL.md:688   do NOT mark `extraction_complete`

Compare the memo's Option A prototype, which produced 5 on the same corpus. Option B suppressed exactly the two I flagged as unfollowable — Never writes files or calls git (already leads a sentence) and (not a clean delete) (definitional aside) — and kept all three real ones. The remaining two do NOT ... hits are mid-cell after a semicolon or dash, which is the smell, and the new message is advice a table author can actually act on.

My 65% confidence in the memo was mostly about whether the per-sentence heuristic would hold up beyond the 5 sampled rows. It separated the corpus cleanly, so I'd now put it higher.

Labelled review, not closed.

**Option B implemented** per Jared's decision. Branch `aidd-lint-trailing-prohibition-gaps`, commit `1d29fb8`. TDD. ## What shipped New `AiddLint::TableCells` (`lib/aidd_lint/table_cells.rb`) plus a `check_cell` path on the cop. Rather than write a new row parser, it builds on the **existing `TableMask`**, which was already in the codebase and already requires a header row followed by a separator row. That means a stray pipe line in prose is not mistaken for a table — pinned by `test_ignores_a_pipe_row_that_is_not_part_of_a_real_table`. `ListItems::MARKER` is untouched, so no other cop's scanning surface moved. Three cell-specific rules, each pinned by a test: 1. **Exemption is sentence-level, not clause-level.** A cell is exempt when *any* sentence in it leads with the prohibition. This is the crux of Option B — a clause-level gate would have suppressed the ticket's own target (`— never raise it recklessly` is a clause that leads with `never`), while a sentence-level gate keeps it and still exempts `... Never writes files or calls git; never re-reads disk.` 2. **Parenthetical-"not" is not carried over to cells.** Cells are dense with definitional asides — `(not a clean delete)` in a category table is a clarification, not a rule. 3. **Cells get their own message:** `prohibition buried mid-cell ('...') -- lead the cell with it, or move the rule out of the table.` A test asserts the bullet-shaped message does *not* appear on a cell offense. ## Tests ``` 296 runs, 718 assertions, 0 failures, 0 errors, 0 skips ``` Six new tests on this issue: buried mid-cell (the ticket's must-fail), sentence-leads exemption, table-shaped message, parenthetical exemption, separator/clean-cell quiet, non-table pipe row quiet. ## Corpus delta Measured properly this time — baseline run in a detached `git worktree` at the parent commit, same 99 files, so nothing else in the tree could contaminate it. **25 → 28. +3 offenses, 0 regressions, 0 removals.** All three are cell offenses and all three are genuine: os-doc-hygiene/skills/CONTEXT.md:15 it never authors hashes, token counts, safety tiers, or reversibility check/workflows/classify-candidates.md:126 do NOT supply `git_state` or `safety_tier` clean/SKILL.md:688 do NOT mark `extraction_complete` Compare the memo's Option A prototype, which produced **5** on the same corpus. Option B suppressed exactly the two I flagged as unfollowable — `Never writes files or calls git` (already leads a sentence) and `(not a clean delete)` (definitional aside) — and kept all three real ones. The remaining two `do NOT ...` hits are mid-cell after a semicolon or dash, which is the smell, and the new message is advice a table author can actually act on. My 65% confidence in the memo was mostly about whether the per-sentence heuristic would hold up beyond the 5 sampled rows. It separated the corpus cleanly, so I'd now put it higher. Labelled `review`, not closed.
Author
Owner

ADR recorded: ADR-0069 (commit 9ba7190).

Ran /os-adr:find over the cop/scanner paths. It returned ADR-0058/0059/0060/0062/0063/0064/0067/0068 — all about plugin hosting, trigger architecture, the Tier-2 catalog, and which files are in scope (0062). Nothing governed which document regions a cop may read.

That mattered because TableMask's own docstring says it exists "so cops can skip table content when scanning body prose" — this change is the first to deliberately scan into tables, inverting what that primitive was built for. Per cc-os CLAUDE.md ("before changing/replacing an approach this codebase already uses… record it with /os-adr:create"), that needed a record rather than a silent inversion.

docs/adr/0069-tier-1-cops-may-scan-markdown-table-cells-as-a-distinct-surface-with-surface-specific-exemption-and-remedy.md binds any cop that opts in to three constraints, so the next surface-gap ticket has a settled answer instead of relitigating:

  1. Extract cells through the shared TableCells primitive, built on TableMask — one definition of "a table", and ListItems::MARKER is never widened (adding a surface must not move another cop's surface).
  2. Re-derive the exemption rule for the new surface rather than reuse the list-item one.
  3. Emit a surface-specific message naming a remedy that surface's author can actually perform.

TableMask keeps its masking role for prose-scanning cops; scanning into tables is opt-in per cop, not a global redefinition of what a document is. The alternatives section records the measurements behind each rejection, including why a clause-level exemption gate was rejected — it would have suppressed this ticket's own evidence line.

Suite still 296 runs, 718 assertions, 0 failures, 0 errors, 0 skips.

**ADR recorded: ADR-0069** (commit `9ba7190`). Ran `/os-adr:find` over the cop/scanner paths. It returned ADR-0058/0059/0060/0062/0063/0064/0067/0068 — all about plugin hosting, trigger architecture, the Tier-2 catalog, and which *files* are in scope (0062). **Nothing governed which document *regions* a cop may read.** That mattered because `TableMask`'s own docstring says it exists "so cops can skip table content when scanning body prose" — this change is the first to deliberately scan *into* tables, inverting what that primitive was built for. Per cc-os CLAUDE.md ("before changing/replacing an approach this codebase already uses… record it with `/os-adr:create`"), that needed a record rather than a silent inversion. `docs/adr/0069-tier-1-cops-may-scan-markdown-table-cells-as-a-distinct-surface-with-surface-specific-exemption-and-remedy.md` binds any cop that opts in to three constraints, so the next surface-gap ticket has a settled answer instead of relitigating: 1. Extract cells through the shared `TableCells` primitive, built on `TableMask` — one definition of "a table", and `ListItems::MARKER` is never widened (adding a surface must not move another cop's surface). 2. Re-derive the exemption rule for the new surface rather than reuse the list-item one. 3. Emit a surface-specific message naming a remedy that surface's author can actually perform. `TableMask` keeps its masking role for prose-scanning cops; scanning into tables is **opt-in per cop**, not a global redefinition of what a document is. The alternatives section records the measurements behind each rejection, including why a clause-level exemption gate was rejected — it would have suppressed this ticket's own evidence line. Suite still `296 runs, 718 assertions, 0 failures, 0 errors, 0 skips`.
jared closed this issue 2026-07-27 17:05:49 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#159
No description provided.