commit 8e4a088be66a168b35c3f2a3af66f13972c89e83
parent 39fae2a994dfd234210f136af4964ebb75c914c8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:55:40 -0400
editor: a generated tree is shown, not offered for editing
S4's editor half of plans/channel-priority.md. While `settings.channelPriority`
says anything, the lane dispatches from a tree compiled off that document per
tick — so a rule typed into the claim ladder could not change what the lane
does. The compiler wins. Leaving the controls live would make this page the one
place in the editor where a saved change is silently overruled.
So `PolicyTreeEditor` reads `status.policyCompiled` and puts the LADDER in
read-only mode: every control that would rewrite a node is disabled, every
control that would add or remove one is not drawn, and one line says the rules
are generated from the channel priorities and links to where they are set. What
still reads is everything that reads — ordinal, match sentence, claim rail, live
occupancy, pending drill-down.
WHAT STAYS EDITABLE IS EVERYTHING THAT IS NOT THE TREE: enable, worker cap,
order, and the auto-captions opt-in. None is derivable from a channel priority,
none is touched by the compiler (every writer of a policy spreads it), and
moving them to /channels would put lane settings on a channels page. Save still
means something; it writes back the same root it was given.
The editor is NOT deleted for a compiled lane: the bucket and operation axes a
hand-authored leaf can express have no equivalent in the priority model. And for
a corpus with no priorities set — every corpus until one is — nothing changes at
all: `policyCompiled` is false and the page is the page it was.
`data-node-id` lands on every rung. A compiled leaf is recognisable on sight by
its `prio-` id, and that was the one fact about the ladder a test could not
reach; nothing renders it as text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 105 insertions(+), 9 deletions(-)
diff --git a/editor/app/operations/components/ClaimLadder.tsx b/editor/app/operations/components/ClaimLadder.tsx
@@ -18,6 +18,7 @@ export function ClaimLadder({
operations,
data,
ops,
+ readOnly = false,
}: {
root: AutoQueueGroup;
channels: Channel[];
@@ -26,11 +27,17 @@ export function ClaimLadder({
operations: string[];
data: RungData;
ops: RungOps;
+ // The tree is COMPILED from the channel priorities, so it is shown rather
+ // than edited. See LadderRung.
+ readOnly?: boolean;
}) {
return (
- <div className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2">
+ <div
+ className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2"
+ data-policy-compiled={readOnly ? "true" : "false"}
+ >
<p className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- Policy
+ Policy{readOnly ? " (generated)" : ""}
</p>
<LadderRung
node={root}
@@ -45,6 +52,7 @@ export function ClaimLadder({
operations={operations}
data={data}
ops={ops}
+ readOnly={readOnly}
/>
</div>
);
diff --git a/editor/app/operations/components/LadderRung.tsx b/editor/app/operations/components/LadderRung.tsx
@@ -38,6 +38,16 @@ import {
// DEPTH IS THE RAIL, NOT AN INDENT. Each level draws its own hairline on the
// left, and that same hairline carries the claim fill — so nesting and
// occupancy are one mark instead of a margin plus a chip.
+//
+// READ-ONLY IS A RENDERING MODE, NOT A SECOND COMPONENT. When the lane's tree is
+// COMPILED from the channel priorities (settings.channelPriority), editing a
+// rung here could not change what the lane dispatches — the compiler wins — so
+// every control that would rewrite the tree is disabled and the ones that would
+// ADD or REMOVE a node are not drawn at all. What stays is everything that
+// reads: the ordinal, the match sentence, the claim rail, the live occupancy
+// and the pending drill-down. The switches that are NOT part of the tree
+// (enable, worker cap, order, auto-captions) live in PolicyTreeEditor and are
+// unaffected.
export type RungData = {
pendingByLeaf: Record<string, number>;
@@ -80,20 +90,39 @@ export function LadderRung(props: {
operations: string[];
data: RungData;
ops: RungOps;
+ // The tree is generated, so it is shown rather than edited. See above.
+ readOnly?: boolean;
}) {
- const { node, depth, parentId, parentMode, index, siblingCount, data, ops } =
- props;
+ const {
+ node,
+ depth,
+ parentId,
+ parentMode,
+ index,
+ siblingCount,
+ data,
+ ops,
+ readOnly = false,
+ } = props;
const group = isGroup(node);
const active = data.activeByNode[node.id] ?? 0;
const isNext = !group && data.nextUpLeafId === node.id;
return (
- <div className="flex gap-2">
+ // The node id is on the DOM, which is the only way to check by eye (or from
+ // a test) that a compiled leaf is the leaf the runner has: `prio-focus-<slug>`
+ // is a generated id, and an id that does not carry that prefix is
+ // hand-authored. Nothing renders it as text.
+ <div className="flex gap-2" data-node-id={node.id}>
<ClaimRail active={active} capacity={data.capacity} />
<div className="flex min-w-0 flex-1 flex-col gap-2 pb-1">
<div className="flex flex-wrap items-center gap-2">
{group ? (
- <GroupControls node={node as AutoQueueGroup} update={ops.update} />
+ <GroupControls
+ node={node as AutoQueueGroup}
+ update={ops.update}
+ readOnly={readOnly}
+ />
) : (
<LeafControls {...props} leaf={node as AutoQueueLeaf} />
)}
@@ -104,6 +133,7 @@ export function LadderRung(props: {
<input
type="number"
min={1}
+ disabled={readOnly}
value={node.weight ?? 1}
onChange={(e) =>
ops.update(node.id, (n) => ({
@@ -122,6 +152,7 @@ export function LadderRung(props: {
type="number"
min={1}
placeholder="∞"
+ disabled={readOnly}
value={node.maxWorkers ?? ""}
onChange={(e) =>
ops.update(node.id, (n) => ({
@@ -157,7 +188,7 @@ export function LadderRung(props: {
)}
<span className="ml-auto flex items-center gap-1">
- {parentId && siblingCount > 1 && (
+ {!readOnly && parentId && siblingCount > 1 && (
<>
<Button
type="button"
@@ -196,7 +227,7 @@ export function LadderRung(props: {
</Button>
</>
)}
- {parentId && (
+ {!readOnly && parentId && (
<Button
type="button"
size="xs"
@@ -231,6 +262,7 @@ export function LadderRung(props: {
</p>
)}
</div>
+ {!readOnly && (
<div className="flex flex-wrap gap-2">
<AddButton onClick={() => ops.addChild(node.id, ops.makeLeaf("channel"))}>
+ Channel rule
@@ -245,6 +277,7 @@ export function LadderRung(props: {
+ Group
</AddButton>
</div>
+ )}
</>
)}
</div>
@@ -363,9 +396,11 @@ function PendingDrilldown({
function GroupControls({
node,
update,
+ readOnly,
}: {
node: AutoQueueGroup;
update: RungOps["update"];
+ readOnly: boolean;
}) {
return (
<>
@@ -375,6 +410,7 @@ function GroupControls({
{/* Named: this select had no label at all. */}
<select
aria-label="group mode"
+ disabled={readOnly}
value={node.mode}
onChange={(e) =>
update(node.id, (n) => ({ ...n, mode: e.target.value as AutoQueueMode }))
@@ -402,8 +438,18 @@ function LeafControls(props: {
operations: string[];
data: RungData;
ops: RungOps;
+ readOnly?: boolean;
}) {
- const { leaf, channels, platforms, buckets, operations, data, ops } = props;
+ const {
+ leaf,
+ channels,
+ platforms,
+ buckets,
+ operations,
+ data,
+ ops,
+ readOnly = false,
+ } = props;
const type = leaf.match.type;
const ordinal = data.leafIds.indexOf(leaf.id) + 1;
return (
@@ -416,6 +462,7 @@ function LeafControls(props: {
{/* Named: the match-type select had no label. */}
<select
aria-label="rule match type"
+ disabled={readOnly}
value={type}
onChange={(e) =>
ops.update(leaf.id, (n) => ({
@@ -433,6 +480,7 @@ function LeafControls(props: {
{type === "channel" && (
<select
aria-label="channel rule value"
+ disabled={readOnly}
value={leaf.match.value ?? ""}
onChange={(e) =>
ops.update(leaf.id, (n) => ({
@@ -454,6 +502,7 @@ function LeafControls(props: {
{type === "platform" && (
<select
aria-label="platform rule value"
+ disabled={readOnly}
value={leaf.match.value ?? ""}
onChange={(e) =>
ops.update(leaf.id, (n) => ({
@@ -487,6 +536,7 @@ function LeafControls(props: {
<select
value={leaf.match.operation ?? ""}
aria-label="rule operation"
+ disabled={readOnly}
onChange={(e) =>
ops.update(leaf.id, (n) => {
const match = { ...(n as AutoQueueLeaf).match };
@@ -521,6 +571,7 @@ function LeafControls(props: {
<select
value={leaf.match.bucket ?? ""}
aria-label="rule bucket"
+ disabled={readOnly}
onChange={(e) =>
ops.update(leaf.id, (n) => {
const match = { ...(n as AutoQueueLeaf).match };
diff --git a/editor/app/operations/components/PolicyTreeEditor.tsx b/editor/app/operations/components/PolicyTreeEditor.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
+import Link from "next/link";
import {
type AutoQueueGroup,
type AutoQueueLeaf,
@@ -27,6 +28,26 @@ import {
// DRAWING of the tree moved to ClaimLadder/LadderRung, which also render the
// live counts — so what used to be a form sitting above a separate counts list
// is now one object.
+//
+// THE TREE GOES READ-ONLY WHEN IT IS GENERATED, and only the tree.
+// `status.policyCompiled` is true whenever `settings.channelPriority` says
+// anything: the lane then dispatches from a tree compiled off that document per
+// tick, so a rule typed in here could not change what the lane does — the
+// compiler wins (controller/autoRunner.ts, S1). Rather than let the ladder
+// offer edits that are silently overruled, it renders as the statement of
+// dispatch order it is, with one line saying where the order is set.
+//
+// WHAT STAYS EDITABLE IS EVERYTHING THAT IS NOT THE TREE: the enable switch, the
+// worker cap, the order, and the auto-captions opt-in. None of them is derivable
+// from a channel priority, none of them is touched by the compiler (every writer
+// SPREADS the policy), and moving them to /channels would put lane settings on a
+// channels page. The Save button therefore still means something here; it just
+// writes back the same root it was given.
+//
+// It is NOT deleted for a compiled lane, either: the bucket and operation axes a
+// hand-authored leaf can express have no equivalent in the priority model, and a
+// corpus with no priorities set — which is every corpus until one is — edits its
+// trees here exactly as before.
let idSeq = 0;
function newId(): string {
@@ -161,6 +182,9 @@ export function PolicyTreeEditor({
buckets: string[];
operations: string[];
}) {
+ // The lane's tree is compiled from settings.channelPriority, so the ladder is
+ // a reading of dispatch rather than a control on it.
+ const compiled = status.policyCompiled;
const [form, setForm] = useState<Form>(() => formOf(status));
// The last value we know is on disk. Everything dirty-related is a comparison
// against this, so "unsaved" means genuinely unsaved rather than "different
@@ -269,8 +293,21 @@ export function PolicyTreeEditor({
</label>
</div>
+ {compiled && (
+ <p className="text-xs text-muted-foreground">
+ These rules are generated from the channel priorities on{" "}
+ <Link href="/channels" className="underline underline-offset-2">
+ the channels page
+ </Link>{" "}
+ — one tier per channel plus one focus, compiled into all four lanes.
+ Edit them there; the switches on this page are still this lane’s
+ own.
+ </p>
+ )}
+
<ClaimLadder
root={form.root}
+ readOnly={compiled}
channels={channels}
platforms={platforms}
buckets={buckets}