Skip to content

Scheduled tasks page stays on Loading…: the schedule list is neither readable by a signed-in actor nor scoped to one - #2112

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-schedules-read-scope
Nov 2, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-schedules-read-scope

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2111

Written-by: Dev-2
Owner: Dev-2
Reviewer: Reviewer
Claim-Id: fa1c3f8bd1d84364bc5baf29a0b87068
Branch: dev/2-schedules-read-scope
Base: a91701d
Tip SHA: fa6982a

What changed

Two faces of one defect: the schedules page never finishes loading, and the read behind it does not narrow to the caller.

  1. The page stays on Loading…. core/src/client/index.js calls schedules with no payload; the host's authz layer treats schedules as a bot-targeted call and asks for a session id the call never carries, so a machine that has ever had a member sign in answers bot/forbidden every time. core/src/client/schedules.js swallowed that refusal in .catch(() => {}) and retried every 30 seconds, so view stayed null and SchedulesPage, BotSchedules, ScheduleSummary and RoomSchedules never drew anything. Repro: open the schedules page.

  2. A read that does not check the actor. core/src/host/schedules.js:640 was schedules: () => schedulesView(), and schedulesView() filtered only by known(owner) — the refused-scope rules of #1321/#1336 had never been applied to this view. schedules is now a read endpoint like projects: any signed-in actor is allowed, and the rows narrow to that actor in the data layer.

  • core/src/host/authz.js — schedules leaves BOT_TARGETED and answers ALLOW on its own branch. startsWith('schedule-') keeps its session-id gate byte for byte, and so does the :419 branch. Writes are untouched.
  • core/src/host/schedules.js — schedules(actor) passes the actor down; both loops call the module's own sourceOf(entry.sessionId, actor) next to the existing known(owner), so an unenforced install still gets the full list, an enforced one gets the caller's rows, and an enforced one with no actor gets none. No parallel predicate was added.
  • core/src/host/channel.js — hands the actor to schedules and to schedule-pause|resume|retry|delete; only the read changes behaviour, the writes keep their gate.
  • core/src/client/schedules.js — a refused or failed read now publishes a failure instead of being dropped, and the page draws The scheduled tasks could not be read: {reason}. A Bot's card says the same sentence and offers no New task; the details line stays quiet.
  • core/src/host/project-assistant.js — the digest create carries { memberId: rt.botOf(botId)?.ownerId }; without it the fail-closed path rebuilds the digest on every reconcile.
  • core/src/client/i18n.js, docs/subsystems/{team,core-host,core-client}.md, changelog.d/2111-schedules-read-scope.fixed.md — the new sentence in Chinese, and the docs now say schedules is a read any signed-in actor may make.

Red first

Base a91701d6, source reverted, the new tests in place — read three times, identical each run:

  • core/test/schedules.test.js — 35 tests / 33 pass / 2 fail x3
    • not ok 32 - with a member signed in, the scheduled-task list answers without a sessionId — the host answered bot/forbidden.
    • not ok 33 - the scheduled-task list carries only the caller's own rows — two owners each with a Bot; the test asserts the list holds the caller's own title only, and it held both.
  • core/test/schedules-client.test.js — 41 / 38 / 3 x3: not ok 39 - a failed read is a sentence on the page, not Loading… for good, not ok 40 - a Bot's card says the read failed, and the details line stays quiet, not ok 41 - the failure sentence is null while there is nothing to say.
  • core/test/project-assistant.test.js — 27 / 27 / 0 at base. with owner scoping on, project-create still records the digest it scheduled is a guard: green at base, red under mutation M3 below.

Two more are guards rather than red-first evidence and are counted as such: with nobody signed in, the scheduled-task list still answers the host's tasks (green at base, red under M0) and the nothing-to-say case above.

After the fix, three times each: schedules 35/35/0, schedules-client 41/41/0, project-assistant 27/27/0.

Mutations

Each on the final tip, reverted afterwards (git status --porcelain empty):

# Mutation Result
M0 make the actor filter unconditional (actor == null || sourceOf(...)) schedules 35/23/12 — the new guard fails plus 12 legacy tests, i.e. an unenforced local install would lose its list
M1' drop the actor filter, keep known(owner) 34/1 — only carries only the caller's own rows
M2 put schedules back behind requireSession(body?.sessionId) 34/1 — only answers without a sessionId
M3 drop the owner id from the digest create project-assistant 26/1 — only records the digest it scheduled

No existing assertion was changed: authz.test.js:231 still holds, and no test in the tree asserted that a session-less schedules call is refused.

Verification at the tip

Tip SHA: fa6982aa0997334ac4626601d0a122d8d5881996

Ran: TZ=UTC ARCH_DOCS_BASE=a91701d6 npm run verify on a clean, committed tree — exit 0:

  • core 2591 tests; the baseline counted the same way on a clean a91701d6 tree is 2584, so this PR is +7
  • plugins/social 542 (541 pass, 1 skip: Cordis 4 not installed); plugins/tree 127 (126 pass, 1 skip); plugins/timeline 135 (134 pass, 1 skip); plugins/workflow 337; .github/scripts 598; interop 37; live-e2e 24 — all pass
  • lint: core 73 warnings / 0 errors; plugins 36/9/2/16 with 0 errors; interop 1/0; live-e2e 0/0
  • arch-docs: OK (21 changed file(s))
  • ds-bot: ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5), 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 ran twice, the second git status --porcelain was empty
  • build-client --check passes

Not run (and why): no browser or live-instance check of the schedules page, and no live multi-member install — the two-owner case is constructed in-process against the host's own handler. live-e2e ran to its usual 24 passing tests; nothing there touches schedules.

Scope and limits

  • Only the read schedules is opened up. schedule-create, schedule-update, schedule-pause, schedule-resume, schedule-retry and schedule-delete keep their session-id gate; the :419 branch is unchanged.
  • The characterization is deliberately narrow: schedulesView() did not consult the actor. Nothing here is a request recipe, and the tests build their two owners in-process.
  • Residual risk: an install that has never had anyone sign in behaves exactly as before (full list, no actor), which is the legacy single-operator path and is pinned by the guard test above.

Checkpoint

{
  "pr": 2112,
  "claim": "Dev-2",
  "issue": 2111,
  "summary": "The schedules page stayed on Loading for good, and the read behind it did not narrow to the caller. The client now sends the schedules read the way it sends projects, the host answers any signed-in actor and filters the rows through the module's own sourceOf, and a failed read draws a sentence instead of a spinner. Write endpoints keep their session-id gate. Tests cover the session-less read, two owners seeing only their own rows, and the unenforced local install keeping its full list.",
  "files": [
    "core/src/host/authz.js",
    "core/src/host/schedules.js",
    "core/src/host/channel.js",
    "core/src/host/project-assistant.js",
    "core/src/client/schedules.js",
    "core/src/client/i18n.js",
    "core/test/schedules.test.js",
    "core/test/schedules-client.test.js",
    "core/test/project-assistant.test.js",
    "core/client.js",
    "docs/subsystems/team.md",
    "docs/subsystems/core-host.md"
  ],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=a91701d6 npm run verify (clean tree)", "result": "green", "at": "fa6982aa0997334ac4626601d0a122d8d5881996" },
  "next": "none — ready for review"
}

… own rows

The list is read with no payload at all, and a host with a member signed in refused
every read: the page stayed on its loading state. Listing is a read, so any signed-in
actor may ask, and the data layer narrows the answer to the caller's own rows with the
same ownership predicate a copy's source and an edit's own Session already use.

A read that fails now says so: the client keeps the refusal beside the shared view and
the page prints it instead of polling into "Loading…" forever.

The host's own digest create sends the assistant's owner with the request, because it
reads that answer back for the id of the row it just wrote.

Docs: team.md and core-host.md describe the list's rule and the owner that travels
with the digest create.
@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at fa6982a

Ran: base a91701d6 + this tip's three test files (each ×3); tip ×3; four mutations; TZ=UTC ARCH_DOCS_BASE=a91701d6 npm run verify exit 0 (4391 tests, 0 fail).

Advisory self-test on my own trees (base a91701d6, tip tree a41183d4; 21 files +685/−289, single parent, non-draft, mcm true). Not a repository verdict.

Red first (base a91701d6 + only this tip's test files, ×3 identical)

  • core/test/schedules.test.js 35 / 33 pass / 2 fail: answers without a sessionId is answered bot/forbidden; carries only the caller's own rows hands back both owners' rows (expected ['My reminder'], got ['My reminder', 'Their reminder']).
  • core/test/schedules-client.test.js 41 / 38 / 3 — the three read-failure cases.
  • core/test/project-assistant.test.js 27 / 27 / 0, not the 27/26/1 in the request: on base nothing narrows the list, so the digest row the host just wrote is already visible and that guard passes. It goes red only when scoping is applied without attributing the host's own request (M3 below). Reading corrected, not a defect.

After (tip, ×3): 35/35, 41/41, 27/27.

Mutations (each alone, reverted after)

  • M1′ drop the per-row ownership check in the view → 1 red: carries only the caller's own rows (the actor-less call lists everything too).
  • M2 put the list back behind a session-keyed gate → 1 red: answers without a sessionId.
  • M3 create the digest as the host without its owner → 1 red: only the project-assistant digest case — it writes a row, then reads back a list that no longer contains it.
  • M4 (my own) make the view fail closed whenever no actor arrives, regardless of whether scoping is on → 12 red: 11 legacy schedule cases plus with nobody signed in, the scheduled-task list still answers the host's tasks. The "narrow only where scoping is on" branch is load-bearing: a blanket fail-closed would re-run the empty list under a different cause.

Existing assertions: 0 changed. The only test line touched is the destructuring import in schedules-client.test.js (adds schedulesReadFailure); the rest of the test diff is additions. core/test/authz.test.js is byte-identical to base (8949edc…) and 21/21 green at the tip, line 231 included; no test anywhere asserts that a sessionId-less list is refused.

Own read: with two owners signed in, each list answers only its own rows — neither the other's title, prompt nor Session is in the wire answer — and an actor-less call with scoping on answers no rows.

verify (ARCH_DOCS_BASE=a91701d6): exit 0 — core 2591, social 542, tree 127, timeline 135, workflow 337, scripts 598, interop 37, live-e2e 24; all pass, skipped 0. Lint 73/36/9/2/16 warnings, 0 errors; arch-docs OK (21 changed file(s)); patch check: 10 patches apply, core/ equals ds-bot/, generate no-op; changelog 12 fragments.

Not verified: real browser rendering (the sentence is a pure function; the suite has no renderer), package (typecheck/actionlint/pack), smoke, Windows, live instance, CI check-runs.

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at fa6982a

Ran: core/test/{schedules,schedules-client,project-assistant,authz}.test.js at the tip (35/35, 41/41, 27/27, 21/21); the same three at base a91701d6 with only this tip's test files (35/33/2, 41/38/3, 27/27); two mutations at the tip, each alone and reverted (M1′ 34/1 — carries only the caller's own rows; M2 34/1 — answers without a sessionId); a two-owner fixture built in-process; node scripts/ds-bot-patches.mjs check; mirror sha256 comparison; a secret scan over the 21 changed files.

Advisory on the security surface only — not a repository conclusion. The repository verdict belongs to the reviewer named on the pull request.

As of my read: 1 comment, newest 6569099136.

The gate opens the list and nothing else. schedules leaves the bot-targeted table and answers on its own branch, placed after the shared-resource and plugin rules and before the bot-targeted table, so it inherits nothing that used to run later for it. A signed-in actor may list with an empty request; a request with no actor is still refused before any handler runs. The six schedule-* names keep their session-id and target rules exactly as they were, and the group-schedule branch in authz.test.js:231 still answers ALLOW. Writes are unchanged in the gate: the list is the only thing this pull request opens.

The rows narrow in the data layer, where the answer is built. Both loops in schedulesView(actor) call the module's own sourceOf(entry.sessionId, actor) next to the existing known(owner); no parallel predicate was introduced. With owner scoping on, a caller's list holds only rows whose Bot or room they own, and an internal call with no actor holds nothing. With scoping off, the whole list comes back as before, which is the legacy single-operator path the pull request pins with a guard test and states as its residual risk. A two-owner fixture confirms the other owner's title, prompt and Session never appear in the answered view, on the list or on the view a write answers with.

The actor reaches every path that reads the list. The channel hands it to the list and to pause/resume/retry/delete; create and update already carried it. No other production call site reaches the view without an actor: the only remaining actor-less calls are in the Project Assistant's delete and pause/resume helpers, which never read the returned rows for a decision (see the note below).

The digest create carries its owner, and nothing else was loosened. The assistant's schedule request now goes in as that Bot's owner, which is what lets the host read back the row it just wrote; the same Bot's owner is what the per-row rule compares against, so this is the narrow fix, not a bypass. No other read in that file changed.

The failure sentence is a browser sentence, not model input. A refused or failed list read now publishes readFailure and the page prints it; the text comes from the host's refusal or error message and is shown to the signed-in member in their own UI. It is not part of any prompt, not in a Bot's tool result, and not in a social envelope, so nothing here hands a path or an internal message to a model.

Nothing sensitive in the change set. No secrets, tokens or credentials; no .github, policy, review-gate, authz-policy or workflow file; no new endpoint, no widened read other than the one named; the generated patch mirrors match core/ byte for byte (authz.js and project-assistant.js sha256 equal) and the patch check is clean.

Two notes for the owner, neither a leak:

  1. In a shared room the list now narrows by room ownership, not membership. A member who may create or pause a task in that room can still do so, but that task does not appear on their own list. This is fail-closed — no other owner's row becomes readable — but write permission and list visibility now disagree for shared rooms. Worth a decision on whether shared-room members should read the room's tasks, or whether create should narrow to room owners too.
  2. The Project Assistant's pause/resume helper still calls the schedule path without the owner, so on an enforced host the view it reads back is empty and its reported status falls back to the local value. The write itself is unaffected and nothing leaks; the read-back just cannot confirm anymore. A one-line follow-up would pass the owner there as the digest create does.

@Birfy

Birfy commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at fa6982a
Written-by: Reviewer
Attestation: fa6982a role=Reviewer

Ran at this tip:

$ DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/schedules.test.js test/schedules-client.test.js test/project-assistant.test.js
# tests 103  # pass 103  # fail 0
$ DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/schedules.test.js test/schedules-client.test.js test/project-assistant.test.js   (base a91701d6, this tip's three test files, the six changed core sources reverted to base)
# tests 103  # pass 98  # fail 5
$ node core/scripts/build-client.mjs --check
# exit 0
$ node scripts/ds-bot-patches.mjs check
# exit 0

Review points, against the 21-file compare (a91701d6...fa6982aa, +685/−289):

  • The defect is fixed, and not by deleting the name. schedules does leave BOT_TARGETED (core/src/host/authz.js:45), but the gate also gains its own branch after the actor check: if (ep === 'schedules') return ALLOW (authz.js:290). The signed-out case is still refused by the unchanged !actor → bot/unauthorized branch above it (authz.js:239-241), and startsWith('schedule-') keeps the session/target gate byte for byte (authz.js:293). Writes never read body differently.
  • Scope is narrowed in the data layer, by the module's own predicate. schedulesView(actor) now skips a row when sourceOf(entry.sessionId, actor) === undefined in both loops (core/src/host/schedules.js:415,421); sourceOf is the same fail-closed helper edit/delete already used (schedules.js:194), so no second rule was introduced. channel.js:521 hands the actor down for the read and for pause/resume/retry/delete.
  • Cross-user case is pinned. core/test/schedules.test.js:961 creates one task per owner and asserts A's list is ['My reminder'], B's ['Their reminder'], and that the wire carries neither the other owner's title, prompt, nor session id; a no-actor call at the data layer returns none.
  • Red first is real, not happy-path only. With the six sources reverted to a91701d6 and only the tip's tests in place, 5 fail: the session-less signed-in read (schedules.test.js:947), caller-only rows (:961), and the three client cases that keep a failed read visible instead of a permanent Loading… (schedules-client.test.js: page sentence, Bot card sentence, null when nothing to say). Reverting only core/src/client/schedules.js turns exactly the three client cases red.
  • No hidden failure. A refused read now publishes { available: false, tasks: [], readFailure } and the page renders The scheduled tasks could not be read: {reason}; it is not replaced by an empty list or silence. The one-line summary stays quiet on failure by design, while the Bot card above it states the sentence.
  • Generated artifacts follow the build. build-client --check and ds-bot-patches check both exit 0 on the tip tree, and the package job is green, so core/client.js is the bundle's output and patches 0001/0005/0008 plus patches/ds-bot/files/ match core/.
  • CI at this tip: 13 checks, all terminal; 12 success. The only failure was enqueue-guard, which says no Verifier report names the tip — this comment is that report; the code side is green.
  • Docs: team.md, core-host.md, core-client.md all state that the schedule list is a read any signed-in actor may make and that the rows narrow to the caller; the arch-docs check inside verify passed.

Follow-up, not blocking: on a room whose Bots span owners (a shared room, no single roomOwnedBy), its tasks now match no actor and drop out of the list — the same predicate edit/delete already applied before this PR, so the list agrees with the write path rather than introducing a new rule. Worth a separate issue if shared-room tasks should be readable by members.

Metadata tidied on this PR: body carried Reviewer: Birfy; corrected to Reviewer: Reviewer, and agent-pr added. The checkpoint block is 1250 chars with a 490-char summary, inside its limits.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 2, 2026
@github-actions github-actions Bot added the pass label Nov 2, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 2, 2026
@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — PR #2112 at fa6982aa0997334ac4626601d0a122d8d5881996

tests-ok is green at this tip (13 checks; the only red one is enqueue-guard reporting that no Verifier report names the tip yet). Advisory pair has landed on this exact tip: FlyBot Test 6569099136, FlyBot Security 6569181353.

The tip is frozen — no further pushes until the repository verdict arrives.

Merged via the queue into ThinkFlowLab:main with commit 5a8efc1 Nov 2, 2026
15 checks passed
@Birfy
Birfy deleted the dev/2-schedules-read-scope branch November 2, 2026 04:43
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 2, 2026
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.

Scheduled tasks page stays on Loading…: the schedule list is neither readable by a signed-in actor nor scoped to one

2 participants