Repository navigation
Refuse a decision name that leaves decisions/ and stop project_state following a symlink - #2038
Conversation
…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.
|
Verdict: PASS at 07c28f6 Ran at this tip: Red first: only the two changed sources ( Why the change is right: the board used Scope ( CI at this tip: 14 check-runs, one red and it is the expected one — Metadata: |
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 -->
…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 -->
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—isDecisionPathis exported: the one shape a decision name has, exactly one segment underdecisions/ending in.md. It was already the rulereadProjectStateallowed; 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.listProjectStateuses it for every file it lists, andreadProjectState(: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 whenisDecisionPath(path)and reads it throughdescribeFile.project-board.js:278— the listing filter isisDecisionPath(file.path).knownis the dedupedfoundset 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 symlinkedPROJECT.mdasabsent, and lists neither.patches/ds-bot/files/**mirrors aregenerateoutput, not hand edits.generatetouched no other file:0001and0008are unchanged (this diff has nocore/src/client/**change).The cause: the board accepted any
decisionFilethat started withdecisions/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.mdbecame a row with that outside file'spathand first line astitle, and the same read that built the past-cut fallback put the name in it.readProjectStatehad the name rule already butstated 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.
actualis what the board projected at the base,expectedis''(and no row); for the reader, the answer the missing-file shape already uses...segment (multi)decisionFile=decisions/zz/../../../outside.md'decisions/zz/../../../outside.md'''decisionFile=<base>/outside.md, and/etc/passwd''decisions/2026-01-02-link.md→<base>/outside.md'decisions/2026-01-02-link.md'''decisions/sub/2026-01-02-deep.md, a real file one level down'decisions/sub/2026-01-02-deep.md'''project_state { file: 'decisions/0002-link.md' }→ outside'# Outside secret\n''absent: decisions/0002-link.md'PROJECT.mdPROJECT.mdreplaced 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.filesas 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.mdstill lists with titleThe real decision, its meeting still getspathand a 64-hexsha256, anddecisions/handwritten-notes.md(no date prefix) lists with titleWritten by hand.Red first on
ed032b86Base source, only the new tests in the tree: 45 tests / 41 pass / 4 fail, three runs, three identical. Re-run the same way after
#2036landed and this branch rebased ontoed032b86: 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
startsWith('decisions/') && endsWith('.md')..and the not-listed case; the symlink case stays green because the file-kind check still refuses it#1702's read ofdecisions/0001-first.md, and the reader's symlink case (it answersnot listedinstead ofabsent)statinstead oflstat(follow the link)lstat, drop theisFile()refusalVerification at the tip
Tip SHA:
07c28f6175cf6c5b66fd34508f91adb4190cb88b— the checks below ran on the rebased local commit69ff3b5e, whose tree is066e63a24e…; 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.arch-docs: OK (10 changed file(s))atARCH_DOCS_BASE=ed032b869566a33ba5d66956d758b5c9e460768f.@flybot/social36,@flybot/timeline9,@flybot/tree2,@flybot/workflow16 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 generateon the rebased, committed tree:canonicalanswersgenerate is a no-op, andgit status --porcelain -unostays empty, so the tree is whatgeneratewrites. The same run rewrote none of the four mirrors beyond this diff, and neither0001nor0008.node core/scripts/build-client.mjs --checkclean (nothing undercore/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 theproject_staterow, the name rule and thelstatread — a symlink answersabsent— and in the Project/board paragraph, that the name is also checked where the listing is produced by the sameisDecisionPath, so no row and no past-cutpath(and nosha256) for a..segment, an absolute path, a symlink or a name this read never listed, witharchivedOfreading through the reader's owndescribeFile.What I did not run
core/dev/smoke.mjs): noindex.jschange and nosrc/host/module registration change; the two edited modules are installed as before, only their reads changed.packagejob (pack check, typecheck, actionlint) and the merge queue itself.core/src/client/**file, soclient.jsand patch0008are untouched andgeneratewrote 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." }