Skip to content

cockpit v3: node refs and the levels block (S1, S3) - #207

Merged
LittleChimera merged 4 commits into
stagingfrom
factory/v3-server-foundations
Nov 3, 2026
Merged

LittleChimera merged 4 commits into
stagingfrom
factory/v3-server-foundations

Conversation

@LittleChimera

@LittleChimera LittleChimera commented Nov 3, 2026 •

Copy link
Copy Markdown

Related issue

Cockpit v3, plan items S1 (node refs) and S3 (the levels block). Server only. Rebased on staging with #204 and #205 in: I-n refs resolve to the store's initiatives.

Summary

The v3 cockpit addresses every node by one ref and reads its levels from the definition. This PR adds the two foundations every later v3 server PR (S2, S4 onward) builds on. v1 and v2 do not change: every subject, target and thread key the server writes today is stored exactly as before.

ELI5: every box in the level tree gets a permanent name tag (T-16, PR-9876, assistant-core, fx:health). Old notes that called it "task:T-16", "!9876" or "#assistant-core" can still be read back to the same tag. What each level is called ("Feature", "Outcome", …), who answers on it and who may change it are settings the owner edits, not code.

 stored today (kept as is)            read shim (normalize)        node
 task:T-4 · pr:<repo>#9630 · pr:<id>  ───────────────────────►  T-4 · PR-9630   ──resolve(facts)──► Node(level, kind, title, row)
 project:<slug> · decision:D-7 · …                                <slug> · D-7
 agent free text "T-4", "PR-9630" ──canonical_key on write──► task:T-4 · pr:<id>

Plan bullets → what this PR does

S1. Node refs (P0)

Plan bullet Done
node_refs.py: the grammar for G-n, O-n, ask:…, I-n, R-n, slug, T-n, adhoc:…, PR-n, commit:…, run:…, fx:…, C-n, member:…, env:… omnigent/server/factory/node_refs.py: parse() for every kind in api.md §2, plus product, fx, need:<implicit key>, feature:<implicit key> and D-n. Each kind maps to (tree, level). Ids are bare hex, shas lowercase, and implicit keys can never look like a numbered ref. A project whose slug is a reserved word (product, fx, factory) is project:<slug>.
resolve(facts, ref) resolve(RefFacts, ref) -> Node | None returns ref, kind, tree, level, title, implicit, row. A spelling that names a repo (pr:<repo>#n, the PR URL, pr:<id>) only resolves to that repo's PR; another repo's #n is None. RefFacts.load(deps, fid) reads the store once. S4 fills implicit, asks and commits from its projection. Integration and self projects resolve into the factory tree (item). An adhoc ref resolves only for a PR that no task owns.
Normalize on write: decisions.subject, issues.target, events.subject, chat thread keys canonical_key() is applied on four paths:
• the agent decision route, which reads only the rows its subject names (RefFacts.for_subject: the PRs with that number, the PR with that id, or the slug's project): T-4 → task:T-4, PR-9630/!9630/pr:9630/pr:<repo>#9630/the PR URL → pr:<id> (a spelling that names a repo only on an exact repo and number match; another repo's #9630 is kept as written), a project's bare slug → project:<slug>
• issues.raise_issue, report and dedupe_key
• events.emit and stored_subject
• chat.resolve_thread: product → factory, fx → improvements, an existing project's slug → project:<slug>, PR-n → pr:<id>
Read-time shim for old rows (no migration) normalize(text, facts=None) reads any spelling, old or new, as the ref. Without the store it reads refs, keys, the chat's !n/#slug/p:slug, pr:<repo>#<n>, session:<id> and factory. With the store it also reads pr:<id>, task:<slug>/<key>, task:<id> and loop:<id>, and a session that a task ran becomes a run. No migration and no history rewrite.
Tests: round-trip every kind; task:T-4, member:<id>, pr:<id> normalize; unknown refs → None tests/server/factory/test_node_refs.py (187 cases) and tests/server/factory/test_node_refs_writes.py (6 cases on the real routes and store). Details under Test Plan.

S3. Levels block in the definition (P1)

Plan bullet Done
structure in the definition (definition_blocks.py): levels (key, name, plural, show, debug), responders, verbs per level, authority per level, order column names, tiles_max, fold_single_child StructureDef has these fields:
• levels.<key> = {name, plural, show, debug, authority: {create, move, approval}} for both trees: product › goal › need › feature › project › task › change › run, and fx › area › item
• home, responders (per level, plus the study kind), verbs (per level, plus study), kinds (names of the kinds not named after their level: opportunity, ask, question, proposal, study, adhoc, pr, commit, rule, agent, env, alarm, decision, definition), kind_authority (a study: Scout, within the need's research budget), order {cols, later}, tiles_max (bounds 1–60) and fold_single_child
Level keys and their order are code (TREES). Names must be unique per tree.
Defaults match spec §3.1 Names follow spec §3.1. Goal is when_any, change inline, run debug. Responders follow api.md §3.1. Verbs, kind names and column titles follow the prototype's DATA. Authority follows critique §13: goals by owners, needs by the discovery ledger, features by owners, projects by autonomy, tasks by the lead at once.
Versioned like the rest The block is a normal definition block: one version per change, Undo by re-pin, sparse storage (not stored until it differs from defaults, so a rollback stays readable), not mailed to members, never a trial (not_trialable: "how work is shown, not how it is done").
GET /factories/{fid}/levels omnigent/server/factory/api/levels.py and levels.py. Anyone who can read the factory can call it. It returns trees.{product,factory} = {root, name, home, levels[]}, plus responders, verbs, kinds (a kind named after its level, such as feature, project, task or factory, reads that level's name, so one rename changes both), kind_authority, order, tiles_max, fold_single_child and version. Each level carries its authority. show, debug and hidden are left out when they hold their defaults.
Tests: defaults; rename via PUT /definition shows in /levels; hidden-when-empty computed per factory tests/server/factory/test_levels.py (32 cases). Details under Test Plan.
Agents can never change the structure block (owner-only class) structure. is in OWNER_ONLY_PREFIXES and CLASS_MAP (B), and in staging's AGENT_NEVER, so every seat gets 403 path_not_allowed with "the levels … are set by owners in the cockpit". It is in no seat's AGENT_PATHS, so it is also agent-gated.

Deviations (and why)

  1. Stored subjects and targets keep the key spelling, not the bare ref. api.md §2 says these fields are "normalized to this grammar" and that a migration rewrites today's text into bare refs. I did not do that. v1/v2 web code and about 15 server readers parse the typed prefixes:

    • web: pr: in LiveFeed, runbookModel and activityModel; project: and member: in the control menus; proposal: in decisionsPageModel
    • server: chat cards, detectors, pr_from_subject, task_pulse, waits, slack DM rules
    • issue dedupe keys are built from targets

    Storing T-4 or PR-9630 would break v2. Instead:

    • every key the server writes is a fixed point of canonical_key. A test enumerates every subject builder in the code.
    • other spellings become the node's key (task:T-4, project:<slug>, decision:D-7, pr:<id>).
    • normalize() reads both forms as the bare ref.

    The key form for a node is the thread-key form api.md §3.9 already uses (project:<slug>, task:<ref>, feature:<ref>, need:<ref>, study:<ref>).

  2. show values are always | when_any | when_many | inline, plus a separate debug: true. api.md's sample has "show": "debug" for runs. The brief lists show and debug as two fields, and the prototype uses debug:true, so I followed the brief.

  3. The tree root is not in levels[]. As in api.md, each tree has root, and its display name comes from the root level (name). The root's show/authority stay in the definition for later.

  4. The structure block also holds home, kinds and kind_authority. The owner's rule is "level names and kinds from data". Kind display names (for example "Task (no ticket)") come from the prototype's DATA.kinds, minus the kinds named after their level: those read the level's name, so nothing is said twice.

  5. Level counts for hidden come from stored rows plus implicit parents. If a level below has a node, the level above has at least one (its implicit parent). The factory tree's item count is unknown here, so it is never hidden. S4's projection will replace these counts with exact per-node levels_below.

  6. Thread spellings. chat.resolve_thread now also accepts node refs for the threads that exist today (product, fx, a project's slug, PR-n). need:/feature:/study:/task: threads are left to S11. thread_key() and thread_ref() already map them, and T-4 is still refused with 400 invalid_thread.

  7. One existing test changed. test_telemetry_span_ids asserted that a decision's span keeps the agent's raw PR spelling (pr:9724, PR: <repo>#9724). The decision subject is now that PR's key (pr:<id>). The span still lands on the same PR.

Review follow-ups

Review item What changed
major node_refs: a PR spelling that names a repo was matched by number only, so pr:sample-org/other#9630 became the sample-app PR's pr:<id> _pr keeps the repo from owner/repo#n and the PR URL (Ref.repo, not part of the ref text). RefFacts indexes PRs by (repo.lower(), number). canonical_key rewrites to pr:<id> only on an exact repo and number match, else returns the text unchanged. resolve returns None. With facts, normalize returns None when the number exists only in other repos. As a side effect, pr:<repo>#77 and pr:<id> now resolve to the right row when two repos share #77; PR-77 alone still resolves to None. Tests: the reviewer's three cases plus the PR: <repo>#n and @sha forms, case-insensitive repo names, and the agent route storing pr:sample-org/other#9630 as written.
minor: preview said "Agents can only propose it." for structure (and autonomy) _who says "No agent proposes it." when every changed path is in AGENT_NEVER. Level changes get their own data sentence ("Level names and settings change how the cockpit shows work, not how it is done; nobody is mailed."). Test: test_the_preview_of_a_rename_says_no_agent_proposes_it.
minor: kinds repeated the level names KindsDef keeps only the kinds not named after a level. KIND_IS_LEVEL maps product, goal, feature, project, task, run, factory (→ fx), area and item to their level, and /levels fills them from the level names. The rename test no longer changes structure.kinds.feature; structure.kinds.feature is now refused (422).
minor: no authority for studies structure.kind_authority.study = {create: [scout], move: [], approval: budget}, returned by /levels.
minor: _subject_key loaded every project and PR on each agent ask RefFacts.for_subject reads only the rows the subject names: list_prs(fid, number=n) (new number filter on the store, both backends, with a store test), get_pr(id) or get_project(fid, slug). Free text, T-4, member: and the like read nothing. A parametrized test checks the reads and that the key equals the one from fully loaded facts.
minor: FACTORY.md said every write site stores PR-9630 as pr:<id> Reworded: a decision stores it as pr:<id> when the store knows the PR; events, issues and an unknown PR store pr:9630.

None skipped.

Also fixed (found while getting the suite green): test_two_proposals_at_once_carry_an_answer_out_once (from #204) failed twice under load. proposals.apply_now wrote result=None in its compare-and-set and only wrote result.authority back after the cores ran. A second proposal for the same answer that checked answer_authority.claimed_by in that window found no claim and applied too. The claim now stays on the row while it applies (one line). test_an_answer_stays_claimed_while_its_proposal_applies checks the claim from inside the apply; it failed on staging's code and passes now.

Test Plan

  • uv run --frozen pytest -q -n 8 tests/server/factory tests/stores tests/examples/test_factory_sample_tokens.py tests/examples/test_factory_bundles.py --ignore=tests/stores/test_databricks_volumes_artifact_store.py -p no:cacheprovider: 5199 passed, 1 skipped after the rebase onto staging with factory: no double approvals, an owner-set autonomy level, stale proposals close themselves #204 and factory: outcome-first initiatives — walking skeleton, acceptance runs on the base env, one progress view #205. The databricks store test needs the optional databricks package, which this env does not install. test_discovery_smoke_e2e::test_the_smoke_loop failed once under heavy machine load (the project card had no pl yet) and passed in every run after; this PR does not touch it.
  • New tests:
    • test_node_refs.py (213 cases):
      • every kind round-trips through parse, normalize, key and canonical_key
      • 200 random numbers per numbered kind round-trip
      • uuid spellings normalize to bare hex
      • today's free text normalizes: task:T-4, Task:t-4, member:<id>, pr:<repo>#n, pr:<id> with the store, !n, #n, #slug, p:slug, decision:, proposal:, initiative:, need:O-n, session:<id> as a member or a run, task:<slug>/<key>, loop:<id>
      • 24 unknown texts return None
      • 22 kinds resolve to their node; 22 unknown refs, including a PR number shared by two repos and another repo's #9630, resolve to None
      • a spelling that names another repo is never rewritten or resolved to this repo's PR; a named repo picks its PR where a number alone cannot
      • RefFacts.for_subject reads only the rows a subject names and gives the same key as fully loaded facts
      • every server key is kept (36 builders and literals)
      • other spellings are written as the key
      • thread keys round-trip
    • test_node_refs_writes.py (6 cases), against the real routes and store:
      • an agent decision subject is stored as task:T-4 or pr:<id>; another repo's pr:sample-org/other#9630 is kept as written
      • an issue raised as T-4 is stored as task:T-4, is found as Task:t-4 and cleared as T-4
      • a reported issue on #slug is stored as project:<slug>
      • event subjects are stored as keys
      • product, fx, a slug and PR-n resolve to their threads; nowhere and T-4 stay 400
      • old rows read back with RefFacts.load, and a stored initiative resolves as I-n on the feature level
    • test_levels.py (35 cases):
      • the defaults are the ladder of spec §3.1
      • an untouched block is not stored
      • GET /levels returns both trees, and goal is hidden with no goals
      • viewers can read it and strangers cannot
      • a rename by PUT /definition (feature → Outcome, project → Workstream) shows in /levels with version + 1, and the kinds named after those levels follow with no change of their own
      • the defaults give a study its own authority (Scout, budget), and no kind repeats a level's name
      • the preview of a rename says "No agent proposes it." and has its own data sentence
      • an operator gets 403
      • Atlas and Sentinel get 403 path_not_allowed
      • 16 bad changes are refused (duplicate names, unknown verb or responder, bad home, empty columns, tiles_max out of bounds, unknown key or level, a kind named after its level, an unknown approval)
      • adding a goal un-hides the goal level
      • when_any on need/task and when_many on run follow the stored rows and implicit parents
      • the hidden rules table
  • Updated test_definition_blocks.py (the not-trialable list gains structure), golden/definition_doc_defaults.json (the new block), tests/stores/test_factory_store.py (list_prs(number=) on both backends) and test_answer_authority.py (the claim during apply).
  • pre-commit run --files <changed>: ruff format, ruff check and the repo hooks pass. pyrefly reports the same 28 errors as staging, all in untouched files (runtime/telemetry.py, background/reconcile.py, onboarding/sandboxes/gensee.py).
  • No migration: there is no schema change, so the PostgreSQL upgrade/downgrade round trip does not apply.
  • FORBIDDEN_SAMPLE_TOKENS ratchet: green. Fixtures use sample-org/sample-app, api-version and similar.

How to check by hand on a dev server:

  1. curl -H "Authorization: …" $SERVER/v1/factory/factories/<fid>/levels. You get both trees; goal has "hidden": true while the definition has no goals.
  2. As an owner: PUT /v1/factory/factories/<fid>/definition with {"changes":[{"op":"replace","path":"structure.levels.feature.name","value":"Outcome"},{"op":"replace","path":"structure.levels.feature.plural","value":"Outcomes"}]}. Then GET …/levels shows Outcome and the version goes up by 1. Pinning the previous version is the Undo.
  3. As an agent: sys_factory_propose with a structure.* change returns 403 path_not_allowed.
  4. sys_factory_ask with subject: "T-4". The decision's subject reads task:T-4. With subject: "pr:<another org>/<another repo>#<n of a tracked PR>" it stays as written.

Demo

N/A: server only, no UI in this PR. Compared with the prototype's DATA: GET /levels with the defaults returns the same ladder (trees.product.levels goal…run with when_any/inline/debug, trees.factory.levels area/item), the same responders (product: atlas … run: null … item: sentinel), the same verbs per level and the same column titles (First / Then / After that / Last, Later (held)). It also matches the structure: YAML the prototype prints under n=fx-levels (tiles_max: 12, fold_single_child: implicit_only).

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

The write-path normalization is covered at two levels:

  • a fixed-point test over every subject and target builder in the server
  • route and store tests for decisions, issues, events and chat threads

The full factory suite runs on top of both.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Pull request size: XL label Nov 3, 2026
LittleChimera and others added 4 commits November 3, 2026 20:32
S1. node_refs.py: one grammar for every node of the level tree (product,
G-n, O-n, ask:, need:, I-n, R-n, feature:, <slug>, T-n, adhoc:, PR-n,
commit:, run:, fx, fx:<area>, C-n, D-n, member:, env:), normalize() that
reads any spelling old or new (the read-time shim), resolve(facts, ref)
and the chat thread mapping. Decisions' subjects, issues' targets, events'
subjects and chat thread names are normalized on write to the node's key;
every key the server writes today is stored as it is.

S3. The structure block in the definition: per level name, plural, show,
debug and authority; home, responders, verbs, kind names, order columns,
tiles_max and fold_single_child, with the spec's defaults. Owner-only,
never a trial, and no agent proposes it. GET /factories/{fid}/levels
returns both trees with what is hidden in this factory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- node_refs: a PR spelling that names a repo (pr:<repo>#n, its URL, pr:<id>)
  keeps the repo; canonical_key rewrites it to pr:<id> only on an exact
  repo+number match and resolve() returns None otherwise. RefFacts indexes
  PRs by (repo, number), case-insensitively.
- The agent decision route reads only the rows its subject names
  (RefFacts.for_subject: list_prs(number=n), get_pr, get_project) instead of
  every project and PR of the factory; the store's list_prs takes number=.
- Definition preview: a change only owners make says "No agent proposes
  it."; level changes get their own data sentence.
- structure.kinds names only the kinds not named after their level; /levels
  fills feature, project, task, factory... from the level names, so one
  rename renames both. structure.kind_authority holds a study's authority
  (Scout, within the need's research budget) and /levels returns it.
- docs: decisions store a known PR as pr:<id>; events, issues and unknown
  PRs keep pr:<n>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apply_now's compare-and-set wrote result=None before the cores ran and
only wrote result.authority back at the end, so a second proposal carrying
the same answer that checked answer_authority.claimed_by in between found
no claim and applied too (test_two_proposals_at_once_carry_an_answer_out_once
failed under load). The claim now stays on the row while it applies; a
deterministic test checks it from inside the apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LittleChimera
LittleChimera force-pushed the factory/v3-server-foundations branch from 2a6ac50 to 807edc2 Compare November 3, 2026 20:03
@LittleChimera
LittleChimera merged commit edff6d3 into staging Nov 3, 2026
25 checks passed
@LittleChimera
LittleChimera deleted the factory/v3-server-foundations branch November 3, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant