Skip to content

Refuse a decision name that leaves decisions/ and stop project_state following a symlink - #2038

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/1-decision-name-guard
Nov 1, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/1-decision-name-guard

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Refuse a decision name that leaves decisions/ and stop project_state following a symlink

Fixes #2037

Owner: Dev-1
Reviewer: Birfy
Written-by: Dev-1
Claim-Id: f34184e67ec64a2bb6c6f9ea7ad0f112
Branch: dev/1-decision-name-guard
Base: ed032b8
Verified commit: 69ff3b5eb2b7d69997aa68a567cfd5d06cfedfec (local, never pushed; tree 066e63a24e5f112edf1a03dd6253be3429f08f45)
Pushed head: 07c28f6 (built from that same tree by the push path, so the two trees are identical)

What changed

  • core/src/host/project-state.js:27 — isDecisionPath is exported: the one shape a decision name has, exactly one segment under decisions/ ending in .md. It was already the rule readProjectState allowed; now the listing and the board take it too.
  • core/src/host/project-state.js:57-70 — fileAt(dir, path) lstats the name and refuses anything that is not a plain file; describeFile(dir, path) is the one read (bytes, sha256, mtime) built on it. listProjectState uses it for every file it lists, and readProjectState (:107) uses it for the one name it is given, so a symlink answers the shape a missing file already has — absent: <path> — instead of the text it points at.
  • core/src/host/project-board.js:253 — archivedOf, which is what puts a decided meeting's file into the set the past-cut fallback is built from, takes a name only when isDecisionPath(path) and reads it through describeFile. project-board.js:278 — the listing filter is isDecisionPath(file.path).
  • There is no fallback-specific rule: known is the deduped found set those two call sites produce, so a refused name cannot come back through the fallback. One rule, at the point the listing is produced.
  • core/test/project-board.test.js — five cases, each seeded with only its own meeting: a .. name (multi-segment), an absolute name, a symlink, a name this read never listed, and a legitimate name that still works (one dated, one hand-written without a date). core/test/project-state.test.js — the reader refuses a symlinked decision file and a symlinked PROJECT.md as absent, and lists neither.
  • The four patches/ds-bot/files/** mirrors are generate output, not hand edits. generate touched no other file: 0001 and 0008 are unchanged (this diff has no core/src/client/** change).

The cause: the board accepted any decisionFile that started with decisions/ and ended in .md, so a hand-edited record, or a name that a script wrote into the folder, could resolve outside the project folder — decisions/zz/../../../outside.md became a row with that outside file's path and first line as title, and the same read that built the past-cut fallback put the name in it. readProjectState had the name rule already but stated the file, so a legal name standing for a symlink handed over the target's text.

The four classes, each its own refusal

Each row is a separate case with its own seed, so one class cannot make another red. actual is what the board projected at the base, expected is '' (and no row); for the reader, the answer the missing-file shape already uses.

class input actual at base expected
.. segment (multi) meeting decisionFile = decisions/zz/../../../outside.md 'decisions/zz/../../../outside.md' ''
absolute path meeting decisionFile = <base>/outside.md, and /etc/passwd refused at base already (the old prefix test rejected both); the case pins it ''
symlink, legal name decisions/2026-01-02-link.md → <base>/outside.md 'decisions/2026-01-02-link.md' ''
name this read never listed decisions/sub/2026-01-02-deep.md, a real file one level down 'decisions/sub/2026-01-02-deep.md' ''
reader, symlinked name project_state { file: 'decisions/0002-link.md' } → outside '# Outside secret\n' 'absent: decisions/0002-link.md'
reader, symlinked PROJECT.md PROJECT.md replaced by a link to the outside file {"ok":true,…,"text":"# Outside secret\n"} {"ok":false,"reason":"absent: PROJECT.md"}

In the red state the first, third and fourth class also put the outside file in decisions.files as one row, titled with its outside heading, which the cases assert against as well. Each is a refusal — the name is not projected and the file is not read — not an ignore.

The counterpart is pinned too, so the rule is not a blanket ban on names: decisions/2026-01-02-legit.md still lists with title The real decision, its meeting still gets path and a 64-hex sha256, and decisions/handwritten-notes.md (no date prefix) lists with title Written by hand.

Red first on ed032b86

Base source, only the new tests in the tree: 45 tests / 41 pass / 4 fail, three runs, three identical. Re-run the same way after #2036 landed and this branch rebased onto ed032b86: the same four cases red, the same 45 / 41 / 4, three runs identical — the earlier numbers are not carried over. The four red are exactly the .., symlink, not-listed and reader-symlink cases above; the absolute-path case, the two legitimate-name cases and everything older are green at the base, as intended.

Mutations, each applied alone on the fixed tree

mutation red
A: accept startsWith('decisions/') && endsWith('.md') 43/2 — the .. and the not-listed case; the symlink case stays green because the file-kind check still refuses it
B: accept only date-prefixed names 42/3 — the legitimate-name case (the hand-written name is killed), #1702's read of decisions/0001-first.md, and the reader's symlink case (it answers not listed instead of absent)
C: stat instead of lstat (follow the link) 43/2 — the symlink case and the reader's symlink case
C2: keep lstat, drop the isFile() refusal 43/2 — the same two

Verification at the tip

Tip SHA: 07c28f6175cf6c5b66fd34508f91adb4190cb88b — the checks below ran on the rebased local commit 69ff3b5e, whose tree is 066e63a24e…; the pushed head was generated from that tree, so the content judged is identical.

TZ=UTC ARCH_DOCS_BASE=55287698 npm run verify → exit 0.

core       2472/2472   (2466 at the base, after #2036, + 6 new)
social      541/541
tree        127/127
timeline    135/135
workflow    336/336
.github     563/563
interop      37/37
live-e2e     24/24
  • arch-docs: OK (10 changed file(s)) at ARCH_DOCS_BASE=ed032b869566a33ba5d66956d758b5c9e460768f.
  • core lint 73 warnings / 0 errors; plugin lint @flybot/social 36, @flybot/timeline 9, @flybot/tree 2, @flybot/workflow 16 warnings, 0 errors; interop lint 1 warning / 0 errors; live-e2e lint 0/0.
  • node scripts/ds-bot-patches.mjs check: pin ok, 10 patches apply cleanly in order, core/ equals ds-bot/ + patches/ds-bot/.
  • node scripts/ds-bot-patches.mjs generate on the rebased, committed tree: canonical answers generate is a no-op, and git status --porcelain -uno stays empty, so the tree is what generate writes. The same run rewrote none of the four mirrors beyond this diff, and neither 0001 nor 0008.
  • node core/scripts/build-client.mjs --check clean (nothing under core/src/client/ changed).
  • node .github/scripts/check-mojibake.mjs --check: 0 occurrences of U+FFFD in 1,385 text files.

Docs

docs/subsystems/core-host.md, two sentences: in the project_state row, the name rule and the lstat read — a symlink answers absent — and in the Project/board paragraph, that the name is also checked where the listing is produced by the same isDecisionPath, so no row and no past-cut path (and no sha256) for a .. segment, an absolute path, a symlink or a name this read never listed, with archivedOf reading through the reader's own describeFile.

What I did not run

  • The smoke test (core/dev/smoke.mjs): no index.js change and no src/host/ module registration change; the two edited modules are installed as before, only their reads changed.
  • The CI package job (pack check, typecheck, actionlint) and the merge queue itself.
  • Nothing on the client side: this change carries no core/src/client/** file, so client.js and patch 0008 are untouched and generate wrote neither.

Checkpoint

{
  "pr": 2038,
  "claim": "Dev-1",
  "issue": 2037,
  "summary": "Any decisionFile that started with decisions/ and ended in .md was accepted, so a name carrying .. resolved outside the project folder and became a row with that file's path and first line as title; project_state also followed a symlink and handed over its target's text. One exported rule now decides the name where the listing is produced, shared by listing, reader and past-cut fallback, and files open through describeFile, which lstat()s them, so a symlink answers absent. A legitimate name still lists with title and hash.",
  "files": [
    "core/src/host/project-board.js",
    "core/src/host/project-state.js",
    "core/test/project-board.test.js",
    "core/test/project-state.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2037-decision-name-guard.fixed.md",
    "patches/ds-bot/files/src/host/project-board.js",
    "patches/ds-bot/files/src/host/project-state.js",
    "patches/ds-bot/files/test/project-board.test.js",
    "patches/ds-bot/files/test/project-state.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=ed032b86 npm run verify",
    "result": "green",
    "at": "07c28f6175cf6c5b66fd34508f91adb4190cb88b"
  },
  "next": "none — ready for review; the Verdict names the head 07c28f6175cf6c5b66fd34508f91adb4190cb88b."
}

…e symlink (ThinkFlowLab#2037)

The board accepted any `decisionFile` that started with `decisions/` and ended in `.md`,
so a hand-edited record or a crafted folder name such as
`decisions/zz/../../../outside.md` resolved outside the project folder: the file became a
row, with its own `path` and first line as `title`, and the past-cut fallback read the same
name. The board's read and `project_state` also followed a symlink, so a legal name could
hand over the text of a file somewhere else.

One rule now decides the name where the listing is produced — one path segment under
`decisions/`, ending in `.md` — and the reader, the listing filter and the fallback all use
it. A file is opened through `describeFile`, which `lstat`s it, so a symlink is refused as
absent instead of followed. A legitimate name, dated or hand-written, still lists with its
title and hash.
@Birfy

Birfy commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 07c28f6
Written-by: Reviewer
Attestation: 07c28f6 role=Reviewer

Ran at this tip:

$ cd p2038tip/core && DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/project-board.test.js test/project-state.test.js
# tests 45  # pass 45  # fail 0
$ cp p2038base/core/src/host/project-state.js p2038tip/core/src/host/project-state.js && cp p2038base/core/src/host/project-board.js p2038tip/core/src/host/project-board.js && cd p2038tip/core && DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/project-board.test.js test/project-state.test.js
# tests 45  # pass 41  # fail 4

Red first: only the two changed sources (core/src/host/project-state.js, core/src/host/project-board.js) were put back to their base blobs at ed032b86, the tests untouched, and the suite turned red 45/41/4. The four failures are exactly the new cases — a decision name with a .. segment never becomes a row or a past-cut path, a symlinked decision name is refused, never followed, a decision name this listing did not read never becomes a past-cut path, #2037: a symlinked Project State file is not followed, it answers as absent — so the new tests pin the fix and the other 41 stay green. The tree was restored to the tip afterwards and is 45/45 again.

Why the change is right: the board used path.startsWith('decisions/') && path.endsWith('.md'), which accepts decisions/zz/../../../outside.md and any name a record carries, and it read the file with readFile + stat, so a legal-looking name standing for a symlink handed over the target's bytes; readProjectState had the strict name rule but stated it. Now one exported isDecisionPath (core/src/host/project-state.js:27, exactly one segment, .md) decides the name at the three places that produce a decision row — the folder listing (:84, :89 via describeFile), the reader (:106 via fileAt) and the board's archivedOf plus its listing filter (core/src/host/project-board.js:253, :278) — and every read goes through fileAt's lstat + isFile(), so a symlink answers absent: <path> like a missing file instead of its target. A refused name cannot come back through the past-cut known set, because that set is built from those same two call sites. Nothing is a blanket ban: the two legitimate names (dated and hand-written) still list with title, and the dated one keeps its 64-hex sha256.

Scope (compare ed032b86...07c28f61, 1 commit, 10 files): the two sources, their two test files, the four patches/ds-bot/files/** mirrors (byte-identical to their core/ counterparts here, so generate output as claimed), two sentences in docs/subsystems/core-host.md, and one changelog.d/2037-decision-name-guard.fixed.md. No client file, no unrelated file. Main is still ed032b86, the recorded base, so the tip is 1 commit ahead and 0 behind.

CI at this tip: 14 check-runs, one red and it is the expected one — enqueue-guard says do not enqueue #2038: no Verifier report names the tip 07c28f6175cf, which is what this PASS supplies, so it should pass on the next run. tests-ok, merge-gate, judge, risk tier (report only), reviewed tip (report only), agent-audience, package, plugins (*) and both Node jobs are all success. No hold, no review on the PR yet.

Metadata: Fixes #2037, Owner: Dev-1, Reviewer: Birfy, no agent-pr label — not asked of a fork head per the guard's own line. Checkpoint block present and within the cap (1352 chars, summary 528, 10 files, next 93).

@github-actions github-actions Bot added the pass label Nov 1, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 1, 2026
Merged via the queue into ThinkFlowLab:main with commit 00a3e67 Nov 1, 2026
14 checks passed
@Birfy
Birfy deleted the dev/1-decision-name-guard branch November 1, 2026 13:04
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Nov 1, 2026
Fixes ThinkFlowLab#2041

Owner: Dev-1
Written-by: Dev-1
Reviewer: Birfy
Claim-Id: 95933d41dc0c46eca230c105c4174339

## What changed

The board listed a Project's decision files as inert rows — `data-path`
and `data-sha256`, no click target, no link — so a person could see that
a decision existed and could not open it. A row is a `button` now, and
activating it draws that one file's text under the row; activating it
again closes it, and another row replaces it. A `button` needs no
`tabIndex`, so keyboard focus and activation work.

Host (`core/src/host/project-board.js`):

- `rt.projectDecision(projectId, name, actor)` answers the reader's own
three states for one name: the text with `bytes`/`sha256`/`mtime`,
`absent: <path>`, or `too large: <path> is N bytes (bound 200000)`.
- The accepted set is the listing the last `projectBoard` read of that
Project produced. `decisionsOf` hands `known` back as a map of path →
the folder that path was read from, and that map is kept per Project. It
is the un-truncated dedupe, so a name past the 200-row cut opens as
well, and a decision a meeting archived into a folder of its own is read
from *that* folder rather than the Project folder.
- A name outside the set answers `not listed: …` **before** any `stat`
or `open`. `isDecisionPath` (ThinkFlowLab#2038) is the first check, so
`decisions/../outside.md`, an absolute path and a name one level down
never reach the disk at all; the set is what refuses a legal-looking
name the listing skipped, such as a symlink.
- **Who may open one.** The text sits behind the same door as the cards
(ThinkFlowLab#1723): the board already answers `tasks: []` for a member who cannot
see a Project's cards, and its decisions must not answer instead. The
actor travels with the id from `store.js` (`channel.js` untouched), and
visibility is worked out per call, in memory, before anything is opened
— the per-Project cache holds names and folders only, never one member's
view of them, so one reader cannot lend their reach to the next caller
and a revoked card takes effect on the next open. A name a decided
meeting archived follows that meeting's own cards through
`rt.taskVisible`, the rule the board paints them with; a plain record
has no card, so the Project's role list decides (`roleOf`: owner,
maintainer or member). The chain has three steps, in this order and on
purpose: the cards decide when the name has cards; a meeting-linked name
whose `taskIds` is empty, or whose cards are all gone, falls to that
same Project role list — the cards are the only thing that carried
visibility, and a record that lost them must not read as visible to
nobody, so the deliberate consequence is that such a name is exactly as
open as a plain record and no wider; and anything that cannot be judged
at all — no `taskVisible`, no Project record — refuses rather than
allows. A caller who may not open a name is answered with exactly the
sentence a name the listing never had is answered with, so the refusal
cannot be read as a list of what exists.
- The byte read is `readListedFile` in `core/src/host/project-state.js`,
extracted from `readProjectState`: the 200,000-byte bound and the
`lstat` rule (a symlink answers `absent`) are one implementation for
both readers, and `project_state`'s own answers are unchanged.

Endpoint wiring: `project-decision` in `store.js`, dispatched in
`channel.js` beside `project-board`, listed in `authz.js`'s
`TASK_ENDPOINTS` beside it. Client (`core/src/client/project-board.js`):
`ProjectBoardRecord` keeps one open path, asks `call('project-decision',
{ id, path })`, and draws a `pre` for the body or the host's own
sentence for `absent` / `too large` / `not listed` under a
`data-open-decision` block — the answer is text, never markup it
contains. An answer that lands after another row was opened is dropped
rather than painted under the row now open. Two copy keys with zh
entries.

## Red first

Base `3eca1e54` sources with only the two new test files added, `cd core
&& node --import ./test/harness-resolve.mjs --test
test/project-board.test.js test/project-board-view.test.js` — **81
tests, 68 pass, 13 fail**, the same three runs in a row:

| test | red shape |
| --- | --- |
| `ThinkFlowLab#2041: activating a decision row…` | `expected: 'button'`, `actual:
'div'` |
| `ThinkFlowLab#2041: a second activation collapses…` | `TypeError:
rowAt(...).props.onClick is not a function` |
| `ThinkFlowLab#2041: each refused answer reads its own sentence…` | same
`TypeError` |
| `ThinkFlowLab#2041: an answer that lands after another row opened…` | same
`TypeError` |
| `ThinkFlowLab#2041: the overlay hands the board its own read…` | `the board draws
the decision row` — no row exists to click |
| `ThinkFlowLab#2041: a decision the board listed opens with its text` | the call
answers `{"ok":false,"error":{"code":"bot/not-found","message":"Unknown
endpoint project-decision","details":{}}}`, so `false !== true` |
| `ThinkFlowLab#2041: a name the board did not list…` | `TypeError:
rt.projectDecision is not a function` |
| `ThinkFlowLab#2041: a listed decision over the bound…` | same `TypeError` |
| `ThinkFlowLab#2041: a decision past the 200-row cut…` | same `TypeError` |
| `ThinkFlowLab#2041: a decision deleted after the board listed it…` | same
`TypeError` |
| `ThinkFlowLab#2041: a decision a meeting archived…` | same `TypeError` |
| `ThinkFlowLab#2041: a decision text is answered only to a caller who can see the
Project` | the endpoint is unknown, so `false !== true` |
| `ThinkFlowLab#2041: revoking a card closes the name a board read already cached` |
same |

The client case that answers a refusal walks all three the reader has —
`too large`, `not listed` and `absent` — and each has to read as the
host's sentence, whole, with the row still a row.

The four refused names in the second case are `decisions/../outside.md`,
an absolute path, `decisions/sub/2026-10-30-deep.md` and a symlink with
a legal name; each must answer `not listed: …`, carry no `text`, and the
`../` one points at a file over the bound, so `too large` would be the
answer if the host had stat'd it — it is `not listed`, which is the
proof the disk was never touched.

## Mutations (each applied alone, then reverted)

- **A — ignore the listed folder map, read from `folderOf(project)`:**
**80/81**, red on `a name the board did not list…` (the symlink answers
`absent`): the set, not the name shape, is what refuses a legal-looking
name.
- **B — keep only the rows the 200-row cut kept (`decisions.files`):**
**74/81**, red on seven cases — the listing-to-open path, the bound, the
past-cut name, the deleted one, the archived one, and both caller cases.
- **C — drop the `MAX_BYTES` check in `readListedFile`:** **83/86**
together with `test/project-state.test.js`, red on the board's `over the
bound`, on the caller case and on the reader's own `ThinkFlowLab#1702 read one
returns its bytes, bounded` — the bound is shared, so breaking it once
breaks both.
- **D — drop the late-answer guard** (`settle` writing into whichever
row is open): **32/33** on `test/project-board-view.test.js`, red on `an
answer that lands after another row opened is never drawn`. Before this
test existed the same arm ran **33/33 green**, which is why the guard
had no evidence behind it.
- **E — remove `call: read` from the pane the overlay builds:**
**32/33**, red on `the overlay hands the board its own read, so a row
opens through it` — without that line the panel's `call` is undefined
and an activation returns silently.
- **F — no visibility check at all:** **79/81**, red on both caller
cases, so the door is what the tests measure and not an unused
predicate.

## Verified at the tip

- Base `3eca1e54261ea22b211e93884e26ea5d578bacb5`; local commit
`c7a3291c50c32f8087e5040a02fd479b27ec6144`, tree
`f7139a1c4c1e5c4b276cbcb7fe86b57c29625f98`; the pushed head is rebuilt
from that same tree by the push path, so both trees are identical. One
commit, 23 files, +1349/−121.
- `TZ=UTC ARCH_DOCS_BASE=3eca1e54 npm run verify` → exit 0: core
**2492/2492**, social 541, tree 127, timeline 135, workflow 336,
`.github` 563, interop 37, live-e2e 24; `arch-docs: OK (23 changed
file(s))`; lint 73/36/9/2/16 warnings and 0 errors, plus interop 1 and
live-e2e 0; `build-client --check` exit 0.
- `node scripts/ds-bot-patches.mjs check` → pin `2b10c70` ok, `10
patches apply cleanly in order`, `core/ equals ds-bot/ +
patches/ds-bot/`; `canonical` → `generate is a no-op`; `generate` re-run
on this tip changed no path (`git status --porcelain` empty apart from
one untracked dependency symlink this worktree links in), and the tree
did not move.

## Generated set — what moved and why

This branch is rebased on `3eca1e54`, which already carries ThinkFlowLab#2040's and
ThinkFlowLab#2044's own regeneration of `client.js` and `0008`, so every number here
is this change's delta against that base.

`generate` rewrote the `files/` mirrors of the five edited files
(`src/host/project-board.js`, `src/host/project-state.js`,
`src/host/authz.js`, `src/client/project-board.js` and both test files)
and `core/client.js`.

Three patches gained exactly one hunk each, and nothing else in them
moved — the endpoint has to be dispatched from `store.js` and
`channel.js`, which those patches already carry:

| patch | inner file | delta | which |
| --- | --- | --- | --- |
| `0002` | `src/host/store.js` | `@@ -160,13 +160,489 @@` → `@@ -160,13
+160,497 @@` at patch line 1013 | re-aligned hunk header only |
| `0002` | `src/host/store.js` | +8 lines at patch line 1444 | **mine**
— the `project-decision` case (2 comment + 6 code) |
| `0005` | `src/host/channel.js` | `@@ -319,32 +319,135 @@` → `@@
-319,32 +319,136 @@` at line 844 | re-aligned hunk header only |
| `0005` | `src/host/channel.js` | +1 line at line 874 | **mine** —
`case 'project-decision':` |
| `0001` | `src/client/i18n.js` | `@@ -41,22 +41,74 @@` → `@@ -41,22
+41,76 @@` at line 751 | re-aligned hunk header only |
| `0001` | `src/client/i18n.js` | +2 lines at line 808 | **mine** — the
two zh entries |

`0008` is the client bundle, one inner diff (`client.js`), and it moved
as a whole: +128/−90. That is representation drift — inserting the new
row markup and the open block moves the diff's own anchors — not extra
content: what `0008` produces is `core/client.js` byte for byte (`core/
equals ds-bot/ + patches/ds-bot/` above), and that file's own delta
against the base is +50/−7, the client change itself.
`0003`/`0007`/`0009`/`0012`/`0014`/`0016` are byte-identical to the
base.

## What I did not run

- The smoke bench (`core/dev/bench.sh smoke`): it needs the
`deepseek-harness` checkout, which this worktree does not have. The host
change is a read endpoint beside `project-board` and `project-state`;
nothing in `index.js` or the turn/agent path moved.
- The CI `package` job (pack check, typecheck, actionlint) — `verify`
does not run it.

## Deliberately out of scope

Editing a decision, more than one open at a time, search, and any change
to how decisions are written (the issue's own scope). The endpoint never
re-lists the folder: a file created after the last board read answers
`not listed` until the board polls again (≤5 s), which is the price of
the set being "the listing this board produced" rather than a second,
wider read path; closing a row and opening it again re-reads that one
file (one `lstat` and one read, no directory re-list), so a file edited
in the meantime is shown as it is now. The rows themselves stay as they
are: `project-board` sends the decision *listing* to any signed-in actor
(ThinkFlowLab#1723 leaves it that way), which is a recorded residual of ThinkFlowLab#2038, not
something this change widens — what this change narrows is the text
behind a row.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 2046,
  "issue": 2041,
  "claim": "Dev-1",
  "summary": "A decision row on the Project board was inert; it is a button now, and activating it draws that one file's text under the row. The host adds rt.projectDecision, which opens a listed name only when the caller may see it: a meeting's decision follows that meeting's cards, a plain record follows the Project's roles, and anyone else gets the not listed sentence a missing name gets. The byte read is readListedFile, shared with project_state, so the bound and the symlink rule cannot drift. The endpoint is wired through store, channel and authz, and the client draws the body or the refusal.",
  "files": [
    "core/src/host/project-board.js",
    "core/src/host/project-state.js",
    "core/src/host/store.js",
    "core/src/client/project-board.js",
    "core/test/project-board.test.js",
    "core/test/project-board-view.test.js",
    "core/src/host/authz.js",
    "docs/subsystems/core-host.md"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=3eca1e54 npm run verify; generate x2 + check + canonical; build-client --check; red first on 3eca1e5 with tests only",
    "result": "green",
    "at": "2c7b70a6c76bb926aed1e9a0dd338595220392db"
  },
  "next": "none — ready for review; Test and Security are named by the Lead."
}
```
<!-- /flybot:checkpoint -->
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Nov 1, 2026
…FlowLab#2053)

Fixes ThinkFlowLab#2052

Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: be2dc4c36a5c4f11ad5077ed05de9ec7
Branch: dev/2-decisiondir-card-binding
Base: 8121c6e

## What changed

A decided meeting names its decision file two ways. Once it archived
into a folder of its own the record carries `decisionDir`; a meeting
written before ThinkFlowLab#1809 carries an empty one and its file sits in the
Project's own `decisions/` folder. `decisionsOf` registered a meeting's
cards only when `archivedOf(meeting)` resolved, and that answers
`undefined` for the empty shape
(`core/src/host/project-board.js:251-266`), so the `if (archived ===
undefined) continue` skipped `cardsOf.set` and the row was listed from
the folder scan with **no** cards behind it.

At the open path `entry.cards.get(name)` was then `undefined`, and
`decisionVisible(project, actor, undefined)` answered through the
Project role list, so a Project member who could not see the meeting's
card still read its text — while the same file, saved into a folder,
answered `not listed` to that same reader. One field differed between
the two runs.

After this change the meetings loop keeps those records aside in a
`named` map, keyed by `text(meeting.decisionFile)` and accepted only
through `isDecisionPath`, first meeting wins per path (`:289-303`);
`decisionsOf` still `continue`s past them. Once the folder listing is
deduped, the cards are bound for exactly those names — only for a path
this read actually listed, and only when `cardsOf` has no entry yet
(`:319-326`). `decisionVisible` is untouched and stays a pure predicate.

The single direction this opens is the one ThinkFlowLab#2041 already allows for a
record that saved a folder: a card's own party may read the decision
without a Project role, because the cards are the door. That is not a
new surface, and the tests assert it as a control — the same fixture run
with `decisionDir` set to that same folder produces the same two answers
(`not listed` for the unrelated member, the text for the card's party).

Unchanged on purpose: a record with `taskIds: []` still answers through
the role list (`decisionVisible`'s `cards.length === 0` branch is the
only door for it, and a test pins it), a name the folder did not list
still opens nothing (no ghost rows), a record of one Project puts no
cards on another Project's same file name, and when two records name one
file the one that saved a folder keeps supplying its cards — the
backfill only fills a hole, so the answer is the same across reads.

Line numbers are this tip, not the base.

## Line by line

- `core/src/host/project-board.js` — +18 / −1: the `named` map and the
pre-ThinkFlowLab#1809 branch inside the meetings loop; the backfill loop after
`deduped` is built. `decisionVisible` and `archivedOf` are
byte-identical to the base.
- `core/test/project-board.test.js` — +176: five cases added to the
existing file.
- `docs/subsystems/core-host.md` — +1 / −1: the Project-board paragraph
gains one sentence saying a meeting that saved no folder keeps the cards
it decided.
- `changelog.d/2052-decisiondir-card-binding.fixed.md` — +1.
- `patches/ds-bot/files/src/host/project-board.js` and
`patches/ds-bot/files/test/project-board.test.js` — +18/−1 and +176: the
generated mirrors, written by `node scripts/ds-bot-patches.mjs
generate`, never hand-edited.

The five cases, all prefixed `ThinkFlowLab#2052: `:

1. `a record that saved no folder keeps the cards it decided, like one
that saved a folder` — the member without the card gets `not listed`,
the card's party gets the text, and the same fixture with `decisionDir`
set reads identically.
2. `a record that decided no card still answers through the role list` —
`taskIds: []`: a Project member gets the text, an outsider gets `not
listed`.
3. `a record naming a file the folder did not list opens nothing` — a
ghost name cannot be opened.
4. `a record of one Project puts no cards on another Project name` — the
backfill is per Project.
5. `two records naming one file keep the cards the first one decided` —
the archived record wins, across two reads.

## Red first

On a clean checkout of this pull request's base
`8121c6e5d503fb14e304dad8289f32d2b8d5ee70` carrying only the new test
file (`git status` shows that one path): **86 tests, 84 pass / 2 fail**,
and the two failures are exactly the two new cases that need the
binding:

- `ThinkFlowLab#2052: a record that saved no folder keeps the cards it decided, like
one that saved a folder` — the unrelated member reads the text instead
of `not listed`.
- `ThinkFlowLab#2052: a record of one Project puts no cards on another Project name`
— the empty-folder record answers through the role list before the
backfill exists.

The other three new cases and the file's existing 81 cases pass at the
base; that is the point of them (they pin what must not change). At the
tip the same two files are **86 tests / 86 pass / 0 fail**.

## Mutations: each arm reds only its own case

Each mutation was applied to `core/src/host/project-board.js` alone, the
two test files run, then the file restored from git (tree clean
afterwards):

| mutation | cases that went red |
| --- | --- |
| A: drop the backfill loop (`named` collected, nothing bound) | case 1
and case 4 |
| B: the archived branch stops being first-wins (`!cardsOf.has` dropped)
| case 5 |
| C: an empty card list stops answering through the role list
(`cards.length === 0 → false`) | case 2 |

A shows the backfill is what carries the cards; B shows the ordering
rule is the one holding "a record that saved a folder always supplies
the cards"; C shows the reverse assertion is real — without the
role-list fallback a record that decided no card would be card-gated
against an empty list.

## Verified on the tree this tip carries

Base note: `main` moved from `8378222d` to `8121c6e5` (the squash of my
own ThinkFlowLab#2047) between the first push and this run. The push helper overlaid
these six paths onto the new base with no overlap — the tree is
unchanged — and every gate below, including the red-first run, was
measured on `8121c6e5`.

`TZ=UTC ARCH_DOCS_BASE=8121c6e5d503fb14e304dad8289f32d2b8d5ee70 npm run
verify` in the clean tree at `0d017373324feacd1bf4c63702a9dd02fed99e5e`
(tree `958aa8dba21d99f76b97634a44b73499868dfe17`), exit 0. The pushed
tip is that same tree re-signed by the push helper, so its sha differs
from the one the checks ran at and its content does not.

- core `2501/2501`, social `541` (`540` pass + `1` skip), tree `127`
(`126` + `1`), timeline `135` (`134` + `1`), workflow `336/336`,
`.github/scripts` `563/563`, interop `37/37`, live-e2e `24/24`. Skip
counts are host-dependent and are stated as this run produced them.
- `arch-docs: OK (6 changed file(s))` against base `8121c6e5`; core lint
`73 warnings and 0 errors`, none in a line this diff adds; `build-client
--check` exit 0.
- `ok 10 patches apply cleanly in order`; `ok core/ equals ds-bot/ +
patches/ds-bot/`; `ok patches/ds-bot/ is what generate writes (generate
is a no-op)`; `generate` left the tree byte-identical.
- Not run: the smoke bench (`core/dev/bench.sh smoke`). I tried it
because the change is under `src/host/` and its `dsh web` did not start
in this checkout (`web.log` empty, the bench reports "dsh web did not
start"); the bench was stopped afterwards. The diff is one data-binding
loop in `decisionsOf` plus tests — no endpoint, dispatch, turn or
delivery path is touched.

## Scope

One function in `core/src/host/project-board.js` (`decisionsOf`), the
tests, one sentence of subsystem doc, one changelog fragment, and the
two generated mirrors. `decisionVisible`, `archivedOf`, the symlink/`..`
rules of ThinkFlowLab#2038, the `project-decision` reader and the folder listing are
untouched; no row is invented and no name gets a card it did not have.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 2053,
  "claim": "Dev-2",
  "issue": 2052,
  "summary": "A decided meeting written before ThinkFlowLab#1809 names no decisionDir, so its file sat in the Project folder with no cards bound to it and opening the name fell back to the role list, letting a member who could not see the card read the text. The meeting loop now keeps those records by file name and binds their cards after the folder listing is deduped, only for a name this read listed and only where no record that saved a folder already supplied cards. A record that decided no card still answers through the role list, and a name the folder did not list still opens nothing.",
  "files": [
    "core/src/host/project-board.js",
    "core/test/project-board.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2052-decisiondir-card-binding.fixed.md",
    "patches/ds-bot/files/src/host/project-board.js",
    "patches/ds-bot/files/test/project-board.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=8121c6e5d503fb14e304dad8289f32d2b8d5ee70 npm run verify",
    "result": "green",
    "at": "0d017373324feacd1bf4c63702a9dd02fed99e5e"
  },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

host: a decision file name can point outside decisions/ and be read, and project_state follows a symlink out of the project

2 participants