Skip to content
11 changes: 11 additions & 0 deletions docs/state-interaction-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,17 @@ grant write authority by itself. If a selected todo declares
`quota should-run` must route to `boundary_projection_repair` or a concrete
user/controller gate instead of letting the agent perform the protected write.

Write-scope patterns denote sets of possible paths, including future files.
Every path denoted by a Todo's required scope must be covered by the union of
active grants; matching the pattern's spelling as if it were a filename does
not establish coverage. Goal authorization retains case-sensitive `fnmatch`
semantics, including wildcards that can match `/`, independently of lease
conflict comparison. Coverage search is bounded and fails closed when it cannot
prove inclusion; narrow complex declarations to concrete scopes in that case.
Inactive grants may explain missing-scope repair hints but do not authorize the
write. Evaluate them only after active coverage fails; optional hint failures
must not suppress that repair or interrupt an already authorized task.

```mermaid
flowchart TB
subgraph Harness["LoopX control plane"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
build_boundary_projection_repair_hint,
build_state_projection_gap,
build_state_projection_gap_repair_hint,
write_scope_allowed,
)


Expand Down Expand Up @@ -166,21 +165,38 @@ def assert_boundary_repair_reports_missing_scope_and_authority_lineage() -> None

def assert_boundary_repair_respects_scope_patterns_and_capability_gate() -> None:
todo = agent_todo(required_write_scopes=["src/runtime/**"])
assert write_scope_allowed("src/runtime/**", ["src/**"])
assert not write_scope_allowed("src/runtime/**", ["docs/**"])
assert build_boundary_projection_repair_hint(
{"write_scope": ["src/**"]},
agent_summary([todo]),
candidate_should_run=True,
capability_gate={"action": "run"},
) is None
repair = build_boundary_projection_repair_hint(
{"write_scope": ["docs/**"]},
agent_summary([todo]),
candidate_should_run=True,
capability_gate={"action": "run"},
)
assert repair is not None, todo
assert repair["missing_write_scopes"] == ["src/runtime/**"], repair
assert build_boundary_projection_repair_hint(
{"write_scope": ["docs/**"]},
agent_summary([todo]),
candidate_should_run=True,
capability_gate={"action": "ask_owner"},
) is None

for required, covered in [("src/b.ts", False), ("src/c.ts", True),
("src/[b].ts", False), ("src/[ac].ts", True)]:
todo = agent_todo(required_write_scopes=[required])
repair = build_boundary_projection_repair_hint(
{"write_scope": ["src/[z-a!b].ts"]}, agent_summary([todo]),
candidate_should_run=True, capability_gate={"action": "run"},
)
assert (repair is None) is covered, (required, repair)
if repair is not None:
assert repair["missing_write_scopes"] == [required], repair


def assert_boundary_repair_prefers_capability_runnable_candidate() -> None:
blocked_first = agent_todo(
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ export function createEffectRuntimeHandlers(
],
["task_lease.write_scopes.overlap", lazyHandler(() => import("./work_items/task_lease_acquire_decision.ts"), ({evaluateTaskLeaseWriteScopesOverlap}) => evaluateTaskLeaseWriteScopesOverlap)],
["quota.task_orchestration.admit", lazyHandler(() => import("./quota/adaptive_children.ts"), ({admitAdaptiveChildren}) => admitAdaptiveChildren)],
["quota.write_scope_coverage", lazyHandler(() => import("./work_items/task_lease_acquire_decision.ts"), ({evaluateWriteScopeCoverage}) => evaluateWriteScopeCoverage)],
["quota.monitor_poll.commit", lazyHandler(() => import("./quota/monitor_poll_commit.ts"), ({evaluateQuotaMonitorPollCommit}) => evaluateQuotaMonitorPollCommit)],
["presentation.global_risks.project", lazyHandler(() => import("./presentation/global_risk_read.ts"), ({projectGlobalRisks}) => projectGlobalRisks)],
["presentation.decision_notice.project", lazyHandler(() => import("./presentation/decision_notice.ts"), ({projectDecisionNotice}) => projectDecisionNotice)],
Expand Down
61 changes: 36 additions & 25 deletions loopx/control_plane/quota/projection_repair.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations
from .effective_action import EffectiveAction

import fnmatch
from typing import Any

from ...state_projection import actions_are_projection_aligned, is_user_wait_text
from ..effect_runtime import (
EffectRuntimeRemoteError, EffectRuntimeStartupError, effect_runtime_result,
)
from ..todos.contract import (
TODO_TASK_CLASS_ADVANCEMENT,
normalize_required_write_scopes,
Expand Down Expand Up @@ -160,19 +162,21 @@ def build_state_projection_gap_repair_hint(
}


def write_scope_allowed(required_scope: str, allowed_scopes: list[str]) -> bool:
required = str(required_scope or "").strip()
if not required:
return True
for allowed in allowed_scopes:
pattern = str(allowed or "").strip()
if not pattern:
continue
if required == pattern:
return True
if fnmatch.fnmatchcase(required, pattern):
return True
return False
def _write_scope_coverage(
Comment thread
Jim-jimu marked this conversation as resolved.
Resolved
required_scopes: list[str], scope_groups: list[list[str]],
) -> list[list[bool]]:
"""Transport one batch to the same scope owner used by child admission."""
checks = [
{"required": required, "allowed": allowed}
for allowed in scope_groups for required in required_scopes
]
response = effect_runtime_result("quota.write_scope_coverage", {"checks": checks})
covered = response.get("covered") if isinstance(response, dict) else None
if (not isinstance(covered, list) or len(covered) != len(checks)
or any(not isinstance(value, bool) for value in covered)):
raise TypeError("invalid typed write-scope coverage result")
width = len(required_scopes)
return [covered[index:index + width] for index in range(0, len(covered), width)]


def build_boundary_projection_repair_hint(
Expand Down Expand Up @@ -219,19 +223,20 @@ def build_boundary_projection_repair_hint(
return None
boundary = goal_boundary if isinstance(goal_boundary, dict) else {}
allowed_scopes = normalize_required_write_scopes(boundary.get("write_scope"))
missing_scopes = [
scope
for scope in required_scopes
if not write_scope_allowed(scope, allowed_scopes)
]
active_coverage = _write_scope_coverage(required_scopes, [allowed_scopes])[0]
missing_scopes = [scope for scope, covered in zip(required_scopes, active_coverage) if not covered]
if not missing_scopes:
return None
selected_text = protocol_action_text(selected.get("text"), limit=220)
boundary_authority = (
boundary.get("checkpointed_boundary_authority")
if isinstance(boundary.get("checkpointed_boundary_authority"), dict)
else None
)
inactive_entries = [
entry for entry in (boundary_authority or {}).get("entries", []) or []
if isinstance(entry, dict) and entry.get("active") is not True
]
selected_text = protocol_action_text(selected.get("text"), limit=220)
repair: dict[str, Any] = {
"source": "quota.should-run",
"trigger": "required_write_scope_missing_from_goal_boundary",
Expand Down Expand Up @@ -271,11 +276,17 @@ def build_boundary_projection_repair_hint(
"active_write_scope": boundary_authority.get("active_write_scope"),
}
inactive_candidates = []
for entry in boundary_authority.get("entries") or []:
if not isinstance(entry, dict) or entry.get("active") is True:
continue
scopes = normalize_required_write_scopes(entry.get("write_scope"))
if any(write_scope_allowed(scope, scopes) for scope in missing_scopes):
try:
inactive_coverage = _write_scope_coverage(missing_scopes, [
normalize_required_write_scopes(entry.get("write_scope"))
for entry in inactive_entries
]) if inactive_entries else []
except (EffectRuntimeRemoteError, EffectRuntimeStartupError, TypeError):
# Optional provenance hints cannot overturn the active decision or
# hide the repair for uncovered writes. Active failures still raise.
inactive_coverage = []
for entry, entry_coverage in zip(inactive_entries, inactive_coverage):
if any(entry_coverage):
inactive_candidates.append(
{
key: entry.get(key)
Expand Down
146 changes: 142 additions & 4 deletions loopx/control_plane/work_items/task_lease_acquire_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ type GlobToken =
| {kind: "literal"; folded: string}
| {kind: "star"}
| {kind: "question"}
| {kind: "class"; body: string; negated: boolean};
| {kind: "class"; body: string; negated: boolean; members?: CharacterClass};

function globTokens(pattern: string, fold = true): GlobToken[] {
const tokens: GlobToken[] = [];
Expand All @@ -207,9 +207,10 @@ function globTokens(pattern: string, fold = true): GlobToken[] {
if (end >= 0) {
flushLiteral();
let body = pattern.slice(index + 1, end);
const normalized = fold ? {} : fnmatchCharacterClass(body);
const negated = body.startsWith("!");
if (negated) body = body.slice(1);
tokens.push({kind: "class", body, negated});
tokens.push({kind: "class", body, negated, ...normalized});
index = end;
continue;
}
Expand All @@ -225,6 +226,44 @@ type CharacterClass = {
ranges: readonly (readonly [number, number])[];
};

/** Python fnmatch discards reversed ranges before interpreting a leading !.
* Keep chunk-internal hyphens literal; only chunk boundaries denote ranges.
* Folded lease overlap retains its separate, existing class interpretation.
*/
function fnmatchCharacterClass(body: string): {members: CharacterClass; negated: boolean} {
const symbols = [...body], chunks: string[][] = [];
let start = 0, search = symbols[0] === "!" ? 2 : 1;
for (let dash = symbols.indexOf("-", search); dash >= 0; dash = symbols.indexOf("-", search)) {
chunks.push(symbols.slice(start, dash));
start = dash + 1;
search = dash + 3;
}
if (start < symbols.length) chunks.push(symbols.slice(start));
else if (chunks.length) chunks[chunks.length - 1].push("-");
for (let index = chunks.length - 1; index > 0; index -= 1) {
const left = chunks[index - 1], right = chunks[index];
if (left.at(-1)!.codePointAt(0)! > right[0].codePointAt(0)!) {
chunks[index - 1] = [...left.slice(0, -1), ...right.slice(1)];
chunks.splice(index, 1);
}
}
const negated = chunks[0]?.[0] === "!";
if (negated) chunks[0].shift();
if (chunks.length > 1 && chunks[0].length === 0) {
// Removing ! can leave a range separator at the start of the class.
// Python treats that leading hyphen as a literal, not a missing endpoint.
chunks.shift();
chunks[0].unshift("-");
}
const literals: string[] = [], ranges: Array<readonly [number, number]> = [];
for (const [index, chunk] of chunks.entries()) {
literals.push(...chunk.slice(index === 0 ? 0 : 1,
index === chunks.length - 1 ? chunk.length : -1));
if (index > 0) ranges.push([chunks[index - 1].at(-1)!.codePointAt(0)!, chunk[0].codePointAt(0)!]);
Comment thread
Jim-jimu marked this conversation as resolved.
Resolved
}
return {members: {literals, ranges}, negated};
}

/** Class members are code points, and a range keeps its original interval bounds. */
function characterClass(body: string): CharacterClass {
const members = [...body];
Expand Down Expand Up @@ -406,7 +445,7 @@ function fnmatchcase(value: string, pattern: string, descendants = false, fold =
.some((length) => match(valueIndex + length, tokenIndex + 1));
} else if (token.kind === "class") {
const lengths = fold ? classMatchLengths(token.body, symbols, valueIndex)
: classContains(characterClass(token.body), symbols[valueIndex]) ? [1] : [];
: classContains(token.members ?? characterClass(token.body), symbols[valueIndex]) ? [1] : [];
result = token.negated
? (fold ? singleCharacterMatchLengths(symbols, valueIndex) : [1]).some((length) =>
!lengths.includes(length) && match(valueIndex + length, tokenIndex + 1))
Expand Down Expand Up @@ -789,5 +828,104 @@ export function materializeTaskLeaseAcquire(identity: {goal_id: string; todo_id:

/** Goal allow scopes use case-sensitive fnmatch, unlike folded lease overlap. */
export function schedulingWriteScopeAllowed(required: string, allowed: readonly string[]): boolean {
return !required || allowed.some(pattern => pattern === required || fnmatchcase(required, pattern, false, false));
if (!required || allowed.includes(required)) return true;
if (allowed.length === 0) return false;
// A literal requirement still uses the existing path matcher. A glob denotes
// all possible writes, including files that have not been created yet.
if (!/[?*[]/u.test(required)) return allowed.some(pattern => fnmatchcase(required, pattern, false, false));
return scopeLanguageCovered(required, allowed);
}

type ScopeInterval = readonly [number, number];
type ScopeTransition = readonly ScopeInterval[] | null;

/** Compile case-sensitive fnmatch tokens to an epsilon-NFA. null is a star. */
function scopeTransitions(pattern: string): ScopeTransition[] {
return globTokens(pattern, false).flatMap((token): ScopeTransition[] => {
if (token.kind === "star") return [null];
if (token.kind === "question") return [[[0, 0x10ffff]]];
if (token.kind === "literal") return [...token.folded].map(character => {
const code = character.codePointAt(0)!;
return [[code, code]];
});
const members = token.members ?? characterClass(token.body);
const ranges: ScopeInterval[] = [...members.ranges,
...members.literals.map(character => [character.codePointAt(0)!, character.codePointAt(0)!] as const)]
.filter(([low, high]) => low <= high).sort(([a], [b]) => a - b);
if (!token.negated) return [ranges];
const complement: ScopeInterval[] = [];
let start = 0;
for (const [low, high] of ranges) {
if (start < low) complement.push([start, low - 1]);
start = Math.max(start, high + 1);
}
if (start <= 0x10ffff) complement.push([start, 0x10ffff]);
return [complement];
});
}

/** Search required × complement(union(grants)) for a counterexample path.
* Alphabet intervals make wide/negated Unicode classes exact without scanning
* characters or the filesystem. Each grant has its own accepting state.
*/
function scopeLanguageCovered(required: string, allowed: readonly string[]): boolean {
const tokens = scopeTransitions(required);
const requiredEnd = tokens.length;
tokens.push([]); // accepting states have no outgoing transition
const starts: number[] = [], grantEnds = new Set<number>();
for (const pattern of new Set(allowed)) {
starts.push(tokens.length);
tokens.push(...scopeTransitions(pattern));
grantEnds.add(tokens.length);
tokens.push([]);
}
const boundaries = new Set([0, 0x110000]);
for (const ranges of tokens) for (const [low, high] of ranges ?? []) {
boundaries.add(low);
boundaries.add(high + 1);
}
const alphabet = [...boundaries].filter(code => code < 0x110000).sort((a, b) => a - b);
const closure = (positions: readonly number[]): number[] => {
const result = new Set(positions);
for (const position of result) if (tokens[position] === null) result.add(position + 1);
return [...result].sort((a, b) => a - b);
};
const step = (positions: readonly number[], code: number): number[] => closure(positions.flatMap(position => {
const ranges = tokens[position];
if (ranges === null) return [position];
return ranges.some(([low, high]) => low <= code && code <= high) ? [position + 1] : [];
}));
const initial: [number[], number[]] = [closure([0]), closure(starts)];
const key = (left: number[], right: number[]) => `${left.join(",")}|${right.join(",")}`;
const queue = [initial], seen = new Set([key(...initial)]);
// Subset construction can be exponential. Unproven coverage must never grant
// authority; callers can narrow pathological declarations to concrete scopes.
let remaining = 100_000;
for (let index = 0; index < queue.length; index += 1) {
const [left, right] = queue[index];
if (left.includes(requiredEnd) && !right.some(position => grantEnds.has(position))) return false;
for (const code of alphabet) {
if (--remaining < 0) return false;
const nextLeft = step(left, code);
if (nextLeft.length === 0) continue;
const nextRight = step(right, code), nextKey = key(nextLeft, nextRight);
if (!seen.has(nextKey)) {
if (seen.size >= 4096) return false;
seen.add(nextKey);
queue.push([nextLeft, nextRight]);
}
}
}
return true;
}

/** Batch coverage checks through the scope owner used by admission and hints. */
export function evaluateWriteScopeCoverage(value: unknown): JsonObject {
const input = requireJsonObject(value, "write-scope coverage");
if (!Array.isArray(input.checks)) throw new EffectRuntimeRequestError("checks must be an array");
return {covered: input.checks.map((raw, index) => {
const check = requireJsonObject(raw, `checks[${index}]`);
return schedulingWriteScopeAllowed(stringValue(check.required, "required"),
decisionStringArray(check.allowed, "allowed"));
})};
}
Loading
Loading