vikunja: Q61a re-mint, pm key 8 and sync key 9; rows A and B brief; decision 86 (sage)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"at": "2026-10-11T18:11:17.758Z",
|
||||
"origin": "https://tasks.woltje.com",
|
||||
"by": "svc-mosaic-stack",
|
||||
"keys": {
|
||||
"pm": {
|
||||
"id": 8,
|
||||
"title": "mosaic-stack-pm-2026-10-11-q61a",
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"before": {
|
||||
"labels": [
|
||||
"read_all"
|
||||
],
|
||||
"projects": [
|
||||
"read_all",
|
||||
"read_one",
|
||||
"update",
|
||||
"views_buckets_tasks"
|
||||
],
|
||||
"tasks": [
|
||||
"create",
|
||||
"read_one",
|
||||
"update"
|
||||
],
|
||||
"tasks_assignees": [
|
||||
"create",
|
||||
"delete"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"create"
|
||||
],
|
||||
"tasks_labels": [
|
||||
"create",
|
||||
"delete"
|
||||
],
|
||||
"tasks_relations": [
|
||||
"create",
|
||||
"delete"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"labels": [
|
||||
"read_all"
|
||||
],
|
||||
"projects": [
|
||||
"create",
|
||||
"read_all",
|
||||
"read_one",
|
||||
"update",
|
||||
"views_buckets",
|
||||
"views_buckets_post",
|
||||
"views_buckets_put",
|
||||
"views_buckets_tasks"
|
||||
],
|
||||
"projects_views": [
|
||||
"read_all",
|
||||
"update"
|
||||
],
|
||||
"tasks": [
|
||||
"create",
|
||||
"read_one",
|
||||
"update"
|
||||
],
|
||||
"tasks_assignees": [
|
||||
"create",
|
||||
"delete"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"create"
|
||||
],
|
||||
"tasks_labels": [
|
||||
"create",
|
||||
"delete"
|
||||
],
|
||||
"tasks_relations": [
|
||||
"create",
|
||||
"delete"
|
||||
]
|
||||
},
|
||||
"control": [
|
||||
404,
|
||||
4002
|
||||
],
|
||||
"projectsList": 200,
|
||||
"fileMode": "600",
|
||||
"revoked": 7,
|
||||
"oldKeyAfterRevoke": 401,
|
||||
"keysNow": [
|
||||
8
|
||||
]
|
||||
},
|
||||
"sync": {
|
||||
"id": 9,
|
||||
"title": "mosaic-stack-sync-2026-10-11-q61a",
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"before": {
|
||||
"projects": [
|
||||
"read_one",
|
||||
"views_buckets",
|
||||
"views_buckets_tasks_get"
|
||||
],
|
||||
"projects_views": [
|
||||
"read_all"
|
||||
],
|
||||
"tasks": [
|
||||
"read_all",
|
||||
"read_one"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"read_all"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"projects": [
|
||||
"read_all",
|
||||
"read_one",
|
||||
"views_buckets",
|
||||
"views_buckets_tasks_get"
|
||||
],
|
||||
"projects_views": [
|
||||
"read_all"
|
||||
],
|
||||
"tasks": [
|
||||
"read_all",
|
||||
"read_one"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"read_all"
|
||||
]
|
||||
},
|
||||
"control": [
|
||||
404,
|
||||
4002
|
||||
],
|
||||
"projectsList": 200,
|
||||
"fileMode": "600",
|
||||
"revoked": 6,
|
||||
"oldKeyAfterRevoke": 401,
|
||||
"keysNow": [
|
||||
9
|
||||
]
|
||||
}
|
||||
},
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Sage, 2026-10-11: Q61a (Jason, relayed by Mos: "full agentic access", no task or project delete,
|
||||
// no share route). svc-mosaic-stack re-mints the pm and sync keys with added scopes: the pm gets
|
||||
// projects create plus bucket and view writes so the agents install #19's buckets, and the sync bot
|
||||
// gets projects read_all to find child projects. Each new key goes to a .new file (wx, 0600), is
|
||||
// checked with reads only, renamed over the old file, and the old key is revoked. No delete or write
|
||||
// is tried as a test; the bus startup probes run on the next start. Refuses if a bot's keys aren't
|
||||
// exactly the expected old key, or if a scope set holds a forbidden group or permission.
|
||||
// Usage: node remint.mjs https://tasks.woltje.com SVC_PASSWORD_FILE TOKEN_DIR OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync, renameSync, statSync } from "node:fs";
|
||||
const [ORIGIN, PW, DIR, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing to rerun");
|
||||
const BASE = ORIGIN + "/api/v2";
|
||||
const EXP = "2027-01-07T00:00:00Z", MISSING = 2147483647;
|
||||
const KEYS = {
|
||||
pm: { owner: 6, old: 7, add: { projects: ["create", "views_buckets", "views_buckets_put", "views_buckets_post"], projects_views: ["read_all", "update"] } },
|
||||
sync: { owner: 10, old: 6, add: { projects: ["read_all"] } },
|
||||
};
|
||||
const FORBIDDEN_GROUPS = ["projects_shares", "projects_teams", "projects_users", "projects_webhooks", "teams", "users", "tokens"];
|
||||
// Delete on tasks and projects (and their views and buckets) is Jason's alone. Unassign, unlink and
|
||||
// unlabel are deletes of a join row, not of a task, and the pm already holds them.
|
||||
const forbidden = (g, p) => FORBIDDEN_GROUPS.includes(g) || ((g === "tasks" || g.startsWith("projects")) && /delete|duplicate/.test(p));
|
||||
async function api(method, path, body, auth) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (auth) headers.Authorization = `Bearer ${auth}`;
|
||||
const r = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; };
|
||||
const items = (j, l) => { if (!Array.isArray(j?.items)) throw new Error(`${l}: no items array`); return j.items; };
|
||||
const norm = (p) => Object.fromEntries(Object.entries(p).map(([g, l]) => [g, [...new Set(l)].sort()]).sort(([a], [b]) => a.localeCompare(b)));
|
||||
|
||||
const password = readFileSync(PW, "utf8").trim();
|
||||
const tok = must(await api("POST", "/login", { username: "svc-mosaic-stack", password }), "login svc").token;
|
||||
if (!tok) throw new Error("login svc: no token field");
|
||||
const secrets = [password, tok];
|
||||
const keysOf = (owner) => api("GET", `/tokens?owner_id=${owner}&per_page=50`, undefined, tok).then((r) => items(must(r, `keys ${owner}`), `keys ${owner}`));
|
||||
|
||||
// Preconditions for both keys before any write.
|
||||
const plan = {};
|
||||
for (const [role, k] of Object.entries(KEYS)) {
|
||||
const file = `${DIR}/${role}-vikunja.token`;
|
||||
if (existsSync(`${file}.new`)) throw new Error(`${role}-vikunja.token.new exists; refusing`);
|
||||
const l = await keysOf(k.owner);
|
||||
if (l.map((x) => x.id).join() !== String(k.old)) throw new Error(`${role} bot keys are not exactly [${k.old}]; refusing`);
|
||||
const perms = structuredClone(l[0].permissions);
|
||||
for (const [g, ps] of Object.entries(k.add)) perms[g] = [...(perms[g] ?? []), ...ps];
|
||||
for (const [g, ps] of Object.entries(perms)) for (const p of ps) if (forbidden(g, p)) throw new Error(`${role}: forbidden scope ${g}.${p}; refusing`);
|
||||
const oldValue = readFileSync(file, "utf8").trim();
|
||||
secrets.push(oldValue);
|
||||
plan[role] = { file, oldValue, before: norm(l[0].permissions), perms: norm(perms) };
|
||||
}
|
||||
|
||||
const rec = { at: new Date().toISOString(), origin: ORIGIN, by: "svc-mosaic-stack", keys: {}, problems: [] };
|
||||
for (const [role, k] of Object.entries(KEYS)) {
|
||||
const { file, oldValue, before, perms } = plan[role];
|
||||
const title = `mosaic-stack-${role}-${new Date().toISOString().slice(0, 10)}-q61a`;
|
||||
const m = await api("POST", "/tokens", { title, owner_id: k.owner, expires_at: EXP, permissions: perms }, tok);
|
||||
if (m.status !== 201 || typeof m.json?.token !== "string") throw new Error(`mint ${role}: ${m.status} ${m.json?.code ?? ""} ${m.json?.message ?? ""}`);
|
||||
const value = m.json.token;
|
||||
secrets.push(value);
|
||||
writeFileSync(`${file}.new`, value, { flag: "wx", mode: 0o600 });
|
||||
const ctl = await api("GET", `/tasks/${MISSING}`, undefined, value);
|
||||
const list = await api("GET", "/projects?per_page=1", undefined, value);
|
||||
const r = { id: m.json.id, title, expires: m.json.expires_at, before, permissions: norm(m.json.permissions ?? {}), control: [ctl.status, ctl.json?.code], projectsList: list.status };
|
||||
rec.keys[role] = r;
|
||||
if (ctl.status !== 404 || ctl.json?.code !== 4002 || list.status !== 200) throw new Error(`new ${role} key check failed: ${JSON.stringify(r)}; .new kept, old key ${k.old} untouched`);
|
||||
if (JSON.stringify(r.permissions) !== JSON.stringify(perms)) rec.problems.push(`${role} permissions differ from the request`);
|
||||
renameSync(`${file}.new`, file);
|
||||
r.fileMode = (statSync(file).mode & 0o777).toString(8);
|
||||
if (readFileSync(file, "utf8").trim() !== value) rec.problems.push(`${role} file does not hold the new key`);
|
||||
must(await api("DELETE", `/tokens/${k.old}`, undefined, tok), `revoke old ${role} key`);
|
||||
r.revoked = k.old;
|
||||
r.oldKeyAfterRevoke = (await api("GET", `/tasks/${MISSING}`, undefined, oldValue)).status;
|
||||
r.keysNow = (await keysOf(k.owner)).map((x) => x.id);
|
||||
if (r.expires !== EXP) rec.problems.push(`${role} expiry differs`);
|
||||
if (r.oldKeyAfterRevoke !== 401) rec.problems.push(`old ${role} key still answers`);
|
||||
if (r.keysNow.join() !== String(r.id)) rec.problems.push(`${role} bot keys are not exactly the new one`);
|
||||
if (r.fileMode !== "600") rec.problems.push(`${role} file mode not 600`);
|
||||
}
|
||||
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (secrets.some((s) => out.includes(s))) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out, { flag: "wx" });
|
||||
process.stdout.write(out);
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -1811,3 +1811,33 @@ which stay with him. Each item names who decided it and what happened.
|
||||
- Open: projects.create for the pm (asked of Jason via Mos), and Q61
|
||||
(which project the bus watches). Project 32 stays deleted. Jason
|
||||
removed it as a duplicate of #19, and recreate.mjs is gone.
|
||||
86. **The agents bind the bus to #19 and follow nested projects (2026-10-11,
|
||||
Q61a).** Jason, relayed by Mos: "just share them. Get it done. You
|
||||
keep wanting me to do all the manual steps." The agents do every
|
||||
step, and Jason does none of them. This replaces the holds in 85.
|
||||
- ops-01 inserts the team shares on #3, #4 and #33 (team 1 at admin,
|
||||
team 2 at read). Access cascades to their descendants.
|
||||
- Keys re-minted with "full agentic access" and still no task or
|
||||
project delete, no duplicate and no share, team, user or webhook
|
||||
route (Q60). The pm is key 8: key 7's scopes plus projects create,
|
||||
views_buckets, views_buckets_put and views_buckets_post, and
|
||||
projects_views read_all and update. The sync bot is key 9: key 6's
|
||||
scopes plus projects read_all. Keys 7 and 6 are revoked and answer
|
||||
401. Evidence: `agents/sage/work/vikunja-setup/2026-10-11_remint.json`.
|
||||
- The bus binds to #19 "Mosaic Stack". Its kanban gets the five
|
||||
buckets by renaming where the mapping is obvious (To-Do to todo,
|
||||
Doing to in-progress, Done to done) and adding in-review and
|
||||
blocked. Jason's tasks stay where they are. A before-state file
|
||||
comes first.
|
||||
- Child boards are watch-only. The agents never rename or add
|
||||
buckets on them.
|
||||
- Rows A and B (`docs/plans/2026-10-11_multi-project-sync.md`):
|
||||
read-only nested sync now, and verbs across the tree after row A
|
||||
is done. Mos ruled that task.create takes an optional project,
|
||||
which must be an installed project in the tree.
|
||||
- Correction to 85: no adapter archive guard is needed now. The
|
||||
adapter has no project verb, so the pm's projects.update can't
|
||||
reach a project through the bus. The row that adds a project verb
|
||||
adds the guard.
|
||||
- Projects.create is used once now, for "SetSpark" and "Launchpad"
|
||||
under #33, with no tasks.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Multi-project sync: rows A and B (2026-10-11)
|
||||
|
||||
Status: written by Sage, lead. It follows `docs/plans/BRIEF-TEMPLATE.md`.
|
||||
Jason answered Q61a, relayed by Mos: agents see and work his nested
|
||||
projects ("just share them. Get it done."). Mos ruled the shape: row A
|
||||
now, row B after row A is done, and task.create takes an optional
|
||||
project. Lead decision 86 records it.
|
||||
|
||||
Three constraints hold for both rows. A change that breaks one of them
|
||||
is out of scope, whatever else it fixes.
|
||||
|
||||
- A task's ledger ref stays `vikunja:<project>/<task>`. The ledger
|
||||
checks that form on `task_snapshots`, `decisions` and the `events`
|
||||
subject (`packages/bus/schema.sql`), and `task_snapshots` is
|
||||
append-only. Neither row changes `packages/bus/`. Never key a task by
|
||||
its id alone.
|
||||
- `tracker.descendants` defaults to false. With it false, the adapter
|
||||
behaves exactly as v0 does, and every v0 test passes unchanged.
|
||||
- The adapter never writes to a board. A child project is installed
|
||||
only if its one manual kanban view already has the five buckets
|
||||
(`todo`, `in-progress`, `in-review`, `blocked`, `done`), done and
|
||||
default wired, as the root's install check requires. Any other child
|
||||
is watch-only. No bucket, view or project write ships in either row.
|
||||
|
||||
## Multi-project sync: the adapter reads a project tree (row A)
|
||||
|
||||
### Problem
|
||||
|
||||
The adapter watches one project. `settings()` takes one
|
||||
`tracker.project` (`packages/tasks/src/adapter.mjs:21`), startup checks
|
||||
that project alone (`startup.mjs:37`), and sync reads its board and task
|
||||
list only (`sync.mjs:21`, `sync.mjs:45`). A task in any other project is
|
||||
a shape error in the walk (`sync.mjs:59`), and a move to another project
|
||||
is a tombstone (`sync.mjs:123`). Jason's projects nest. The bus binds
|
||||
to #19 "Mosaic Stack", and he wants agents to follow work into its
|
||||
child projects.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Darkwing.
|
||||
- Reviewers: Dewey and Filbert.
|
||||
|
||||
### Files owned
|
||||
|
||||
- `packages/business/src/vars.mjs` and `packages/business/tests/vars.test.mjs`
|
||||
- `packages/tasks/src/adapter.mjs`, `startup.mjs`, `sync.mjs`,
|
||||
`fake.mjs`, `vikunja.mjs`, `index.mjs`
|
||||
- `packages/tasks/tests/` (all files and fixtures)
|
||||
- `packages/tasks/README.md`, `packages/tasks/live/README.md`
|
||||
- `agents/darkwing/work/queue-<row>/`
|
||||
|
||||
`verbs.mjs` is read-only in this row. Verbs keep refusing a task outside
|
||||
the root with `task-project`, as they do now.
|
||||
|
||||
### What ships
|
||||
|
||||
- `tracker.descendants`, a project-layer boolean var, default false.
|
||||
`tracker.project` stays the root.
|
||||
- With descendants on, the tree is the root plus every descendant the
|
||||
sync bot can see through `GET /projects` (key 9 has `projects
|
||||
read_all`), followed by `parent_project_id`. Discovery runs at startup
|
||||
and at each reconcile, not each tick. Archived projects are not in the
|
||||
tree. Record your rule for a project that leaves the tree (archived,
|
||||
moved out, unshared or deleted) in the README and test it. Its tasks
|
||||
become `task.missing` with a reason. The ledger is never rewritten.
|
||||
- Install state per project. The root's install check refuses the
|
||||
business, exactly as today. A child that passes the same check is
|
||||
installed. A child that fails it is watch-only and never refuses the
|
||||
business. Sync records title, done, assignees and comments for a
|
||||
watch-only child, and no bucket state.
|
||||
- Sync covers each project in the tree. A task moved between two
|
||||
projects in the tree gets the `moved` tombstone naming the new project
|
||||
and a snapshot under its new ref. A move out of the tree stays the
|
||||
tombstone it is today.
|
||||
- Poll cost. Probe whether v2.7.0 serves a cross-project task read to the
|
||||
sync key's scopes. If it does, use one read per tick for the tree. If
|
||||
not, read per project and state the per-tick call count in the README.
|
||||
The probe results go in `agents/darkwing/work/queue-<row>/probes.md`.
|
||||
- `fake.mjs` models `parent_project_id`, the project list route,
|
||||
archived projects and access inherited from an ancestor's share.
|
||||
- Tests: descendants off matches v0; a three-level tree; a watch-only
|
||||
child keeps the business ready; a root without buckets refuses; moves
|
||||
root to child, child to child and child to outside; a new child appears
|
||||
at reconcile; a child leaves the tree.
|
||||
- Gate suites: `node --test 'packages/tasks/tests/*.test.mjs'`,
|
||||
`node --test 'packages/business/tests/*.test.mjs'`,
|
||||
`node --test 'packages/bus/tests/*.test.mjs'`, and every
|
||||
`scripts/test-*.sh`.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Verbs on child projects (row B). Any change to `packages/bus/`. Any write
|
||||
to a Vikunja board or project. An archive guard: the adapter has no
|
||||
project verb, so the pm's `projects.update` can't reach a project through
|
||||
the bus. A row that adds a project verb adds the guard.
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey and Filbert approve on the issue, naming the candidate manifest.
|
||||
The suites above pass on Sage's gate rerun. Sage then runs one live read
|
||||
against tasks.woltje.com with descendants on, root #19, read-only, and
|
||||
attaches the tick log.
|
||||
|
||||
## Multi-project verbs: task verbs across the tree (row B)
|
||||
|
||||
### Problem
|
||||
|
||||
After row A, the bus sees child projects but can't act in them.
|
||||
`target()` and `relation()` refuse a ref from another project
|
||||
(`verbs.mjs:74`, `verbs.mjs:79`), `read()` refuses a task that isn't in
|
||||
the root (`verbs.mjs:88`), and `task.create` always posts to the root
|
||||
(`verbs.mjs:355`) with no project argument.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Darkwing, after row A is done.
|
||||
- Reviewers: Dewey and Filbert.
|
||||
|
||||
### Files owned
|
||||
|
||||
- `packages/tasks/src/verbs.mjs`, `fake.mjs`
|
||||
- `packages/tasks/tests/verbs.test.mjs` and fixtures
|
||||
- `packages/tasks/README.md`
|
||||
- `packages/harness/src/tools.mjs` (the `task_create` tool schema gets
|
||||
the optional project) and its test
|
||||
- `agents/darkwing/work/queue-<row>/`
|
||||
|
||||
### What ships
|
||||
|
||||
- Mos's ruling: `task.create` takes an optional `project`. It must be an
|
||||
installed project in the tree; a watch-only or unknown project refuses
|
||||
with a new code the README names. Without it, the task goes to the
|
||||
root, as today.
|
||||
- `target()`, `relation()` and `read()` accept a task in any installed
|
||||
project of the tree. A bucket move uses that project's own view and
|
||||
buckets, not the root's.
|
||||
- A verb on a task in a watch-only project refuses with the same new
|
||||
code.
|
||||
- Tests for each verb across two installed projects, a watch-only refusal
|
||||
and a relation between projects.
|
||||
- Gate suites as row A.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Project verbs (create, rename, archive, move). They need their own
|
||||
brief, an archive guard and a ruling.
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey and Filbert approve on the issue, naming the candidate manifest,
|
||||
and the suites pass on Sage's gate rerun.
|
||||
Reference in New Issue
Block a user