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
|
- Open: projects.create for the pm (asked of Jason via Mos), and Q61
|
||||||
(which project the bus watches). Project 32 stays deleted. Jason
|
(which project the bus watches). Project 32 stays deleted. Jason
|
||||||
removed it as a duplicate of #19, and recreate.mjs is gone.
|
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