diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md index 5b02283204..59c0e130ac 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md @@ -219,17 +219,23 @@ grant for linked Core details. The shipped managed-Goal context grant below is a bounded step; full planning/effect inheritance still needs its typed grant chain, installed receiver adoption and original-route acceptance. -Use managed Goal scope for an authenticated owner's context-delegation grant: -all current and future registered Agents within those Goals inherit it. Avoid -requiring separate enrollment every time a worker joins. Retain exact-recipient -grants for restricted sources, and explicit recipient revocations that override -the Goal grant. New Goals, evidence-read scope and execution permissions are -separate authority; registration or a quoted request cannot expand them. -The shared `collaboration/source_grants.ts` owner resolves the same current -policy for the catalog, direct handoff and peer forwarding. The existing local -operator command can configure a Goal target by omitting `--agent-id`, with -preview, locked apply and readback. This bounded configuration slice does not -qualify settings-UI editing, native receiver adoption or the full M1–M3 journey. +**Local context-delivery default.** An operator-configured source with a verified authorized +sender defaults to all active registered recipients on its selected local registry, +across Goals and later registrations. `local_delivery_scope=selected` deliberately +retains an enrollment boundary; an old enrollment list alone no longer restricts +the default. Explicit Agent and Goal exclusions survive broad restoration and +apply at direct delivery, replay and each parent-forwarding hop. Missing or +malformed source provenance cannot activate the default. Context delivery grants +no evidence-read expansion, remote delivery, execution, claim/lease or protected +operation. Shared TypeScript `collaboration/source_grants.ts` owns the decision; +Python observes registration/provenance and persists operator changes. Qualify +cross-Goal delivery, future registration, revoked replay and original-route return +through the existing App/Lark conversation, without claiming worker adoption from +catalog access. The existing local operator commands preview, apply and read back exceptions. +Restoring a Goal retains its individually revoked Agents; selected scope can +enroll a whole Goal by omitting `--agent-id`. Editing source policy in packaged +settings remains an unqualified configuration journey. Native receiver adoption +and full M1–M3 execution are separate acceptance gates. LoopX state mutations always use the existing typed command boundary, even if initiated through shell. The manager does not edit registry/authority files behind the control plane. Repository modifications use the project's normal worktree/review practice. Scoped merge/deploy authorization may be reused; unrelated payment or trading authority cannot be inferred from it. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 08022d753f..732c26ad7a 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -429,6 +429,10 @@ Use scoped discovery and permitted recovery before requesting manual IDs. Private-owner discovery is broad by default across registered local resources and authorized connected sources; a delivery allowlist, missing live binding or bounded first page must not hide an otherwise visible responsible Agent. +Sender-bound local context delivery now defaults to active registered recipients +across Goals, with current/future registration and explicit revocations resolved +by the shared TS source owner. Selected-audience enrollment remains explicit; +this is neither a remote grant nor proof of worker adoption. Keep discovery, audience evidence access, delegation and execution readiness separate, as specified by [manager §5.5](capable-manager-semantic-handoff-v0.md#55-responsibility-discovery-and-receiver-owned-planning). diff --git a/docs/product/use-cases/steward/golden-queries.md b/docs/product/use-cases/steward/golden-queries.md index 632438ae75..ba66db022c 100644 --- a/docs/product/use-cases/steward/golden-queries.md +++ b/docs/product/use-cases/steward/golden-queries.md @@ -76,6 +76,16 @@ an ordinary public issue and a qualified receiver already doing unrelated review Delivery and read must not pass the test. Require an independent assessment, reuse an explicitly linked existing task when needed, inspect current facts, post the authorized checked reply and return its link to the original request. +A configured, sender-bound source delivers to all registered active local +Agents by default, across Goals and later registrations, without recipient +enrollment. Exercise direct delivery and a receiver's subsequent peer handoff +through the same current source rule. Revoke one Agent and one Goal: the next +attempt must be denied, and restoring the Goal must preserve the Agent exception. +Exercise both revocation orders, including an Agent revoked while its Goal is disabled. +An explicit selected scope retains enrollment; an unknown sender, stopped Goal +or remote binding never gains authority from the local default. Delivery remains +separate from evidence access, task acceptance and execution. + A short answer requires no invented Todo. A deferral names its actual condition and continuation; a monitor responsibility or worker activity is not a task result. diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index 70389bbb0c..39d7a46f61 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -2,55 +2,56 @@ Built-in capability for original intent delivery and receiver-owned replanning. The owner's local manager channel uses registered workers automatically. -External channels need an owner-configured grant in +An external conversation requires a trusted operator's sender-bound policy in `/.local/manager-context/policy.json`: ```json {"schema_version":"loopx_manager_context_policy_v1","sources":{ - "manager.external.example":{ - "sender_ids":["exact-provider-sender"], - "targets":[{"goal_id":"research"}] + "manager.external.0123456789abcdef01234567":{ + "sender_ids":["exact-provider-sender"] } }} ``` -Use the actual connection channel and provider sender identity. Keep this file -private (0600); do not commit it. Missing grants disable external delivery. -For an existing channel with an authorized sender, use the local operator CLI -to preview, grant, or revoke a managed Goal without editing the -policy file by hand: +**Default behavior change:** configured senders can now deliver context to every +active registered Agent in this local registry, across Goals and including future +registrations. Existing enrollment lists do not narrow this default. To retain a +selected audience's previous enrollment boundary, explicitly set +`"local_delivery_scope":"selected"` alongside its `targets` list before upgrading. +Missing policy, missing source, a wrong sender or malformed scope grants nothing. +Keep the policy private (0600); do not commit it. A read grant without a sender +grant is insufficient. This does not grant remote delivery, evidence reads, +worker launch, Todo/lease changes or protected operations. + +The existing local operator commands preview, apply and verify exceptions: ```sh -loopx manager-inbox grant-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research -loopx manager-inbox grant-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --execute +loopx manager-inbox revoke-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --agent-id worker loopx manager-inbox revoke-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --agent-id worker --execute loopx manager-inbox revoke-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --execute +loopx manager-inbox grant-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --execute +loopx manager-inbox grant-delivery-target --channel-id manager.external.0123456789abcdef01234567 --goal-id research --agent-id worker --execute ``` -Pass the same `--registry` and `--runtime-root` used by the manager connection. -Without `--execute`, these commands only preview the target and count change. -Omitting `--agent-id` covers all current and future registered Agents in that -Goal. Use this for the owner's managed scope; a newly registered Agent then needs -no separate enrollment. Supplying `--agent-id` retains one-recipient enrollment -or revocation. Individual revocation is stored in `blocked_targets`, overrides -the Goal grant, and survives reapplying that Goal grant. Explicitly grant the -Agent to restore it. Revoking a Goal removes both its broad and individual grants. -Existing exact-recipient policies retain their scope until a trusted operator -promotes them; evidence read scope alone never becomes delegation authority. - -Grant requires an active registered Goal (and a registered Agent when specified), an existing sender-bound -channel, and membership in any explicit audience Goal read scope. The command -does not create a sender grant, launch the Agent, or grant protected-operation -authority. Revocation also works when the former Agent is no longer registered. -Remove a source/target grant to revoke future delivery, including replay attempts. -The shared TypeScript source-recipient owner resolves registration and exceptions -for discovery, direct Chat handoff and later peer consultation. Provider adapters -verify ingress and perform locked file IO. Missing, malformed or revoked grants -do not record a request; stopped or unreadable Goals are excluded. This config -slice has a CLI preview/apply/readback; the existing Chat uses its resulting -catalog. Editing source grants in the packaged settings UI remains unqualified. -Provider ingress receipts bind the current message digest, channel and sender; -a model cannot create that provenance through its response. +Pass the manager connection's `--registry` and `--runtime-root`. Without +`--execute`, no policy bytes change. `blocked_targets` retains exact Agent and +whole-Goal revocations, including future members. Restoring a Goal preserves +individual revocations, including those recorded while the Goal is disabled; +restore the Goal first, then an individual Agent when needed. Revocation still +works after unregistration. In `selected` mode, granting +a Goal enrolls its current/future Agents; exact grants enroll only that Agent. +An explicit audience read scope still bounds enrollment in selected mode. + +The shared TypeScript source-recipient owner resolves the same current rule for +discovery, direct Chat delivery, replay and later peer consultation. Python verifies +provider ingress and performs locked file IO. Stopped or unreadable Goal activation +is excluded before admission. The existing App and Lark conversation paths consume +this catalog; registration and delivery never prove receiver adoption or execution. +The operator grant editor in packaged settings remains unqualified; this repair +adds no separate configuration page. Remove the sender/source grant to disable +external delivery, including future replay. Existing inbox records are retained. +Provider ingress receipts bind message digest, channel and sender; a model cannot +create or widen that provenance through its response. The existing worker turn-start hook exposes only a bounded pending count and required read command, without copying private content into status projections. diff --git a/loopx/control_plane/collaboration/README.md b/loopx/control_plane/collaboration/README.md index 0e276712c3..1ab94b7adc 100644 --- a/loopx/control_plane/collaboration/README.md +++ b/loopx/control_plane/collaboration/README.md @@ -44,9 +44,12 @@ ingress and policy reader lives in `source_grant_observation.py`; the Chat capability retains its public `authority` API and supplies its own instruction. This removes a dependency from shared coordination to a product adapter without introducing a second policy writer or migrating existing records. The typed -`source_grants.ts` owner resolves exact recipients and managed Goal targets; -Goal grants include future registered Agents while explicit recipient exclusions -remain effective. The existing operator configuration path delegates changes to +`source_grants.ts` owner resolves exact recipients and managed Goal targets. +Authorized sender-bound sources default to all currently registered local +recipients, across Goals and future registrations. Explicit `selected` scope +retains enrollment; exact and whole-Goal exclusions override the default, including +parent forwarding and replay. Provider observations contain only this host's +active registrations; neither scope admits remote execution. The existing operator configuration path delegates changes to that same owner. Read grants and executor permissions do not imply delegation. `inbox.py` adapts the existing private file stores; typed request validation stays diff --git a/loopx/control_plane/collaboration/source_grants.ts b/loopx/control_plane/collaboration/source_grants.ts index edb3406f1c..f9b1c601ec 100644 --- a/loopx/control_plane/collaboration/source_grants.ts +++ b/loopx/control_plane/collaboration/source_grants.ts @@ -3,6 +3,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, requireJsonObject, requireNonEmptyString, requireStringArray } from "../runtime_decode.ts"; type Recipient = { goal_id: string; agent_id?: string }; +type LocalDeliveryScope = "all_registered" | "selected"; function recipients(value: unknown, label: string, exact: boolean): Recipient[] { if (!Array.isArray(value)) throw new EffectRuntimeRequestError(`${label} must be an array`); @@ -18,21 +19,32 @@ function recipients(value: unknown, label: string, exact: boolean): Recipient[] function grants(source: JsonObject) { return { targets: recipients(Object.hasOwn(source, "targets") ? source.targets : [], "context delivery targets", false), - blocked: recipients(Object.hasOwn(source, "blocked_targets") ? source.blocked_targets : [], "blocked context recipients", true), + blocked: recipients(Object.hasOwn(source, "blocked_targets") ? source.blocked_targets : [], "blocked context recipients", false), + scope: localScope(source), }; } +function localScope(source: JsonObject): LocalDeliveryScope { + const scope = Object.hasOwn(source, "local_delivery_scope") ? source.local_delivery_scope : "all_registered"; + if (scope !== "all_registered" && scope !== "selected") { + throw new EffectRuntimeRequestError("local delivery scope must be all_registered or selected"); + } + return scope; +} + function matches(grant: Recipient, target: Recipient): boolean { return grant.goal_id === target.goal_id && (grant.agent_id === undefined || grant.agent_id === target.agent_id); } -function granted(target: Recipient, targets: Recipient[], blocked: Recipient[]): boolean { - return targets.some(row => matches(row, target)) && !blocked.some(row => matches(row, target)); +function granted(target: Recipient, targets: Recipient[], blocked: Recipient[], scope: LocalDeliveryScope): boolean { + return (scope === "all_registered" || targets.some(row => matches(row, target))) && + !blocked.some(row => matches(row, target)); } /** Source provenance is verified by the provider adapter; registration is observed - * afresh. A Goal target covers its current/future Agents, never other Goals, - * evidence reads, protected operations or executor readiness. + * afresh on this host. Authorized sources default to all registered local + * recipients; selected scope opts into enrollment. Neither scope grants remote + * delivery, evidence reads, protected operations or executor readiness. */ export function resolveSourceRecipients(params: JsonObject): JsonObject { const source = requireJsonObject(params.source, "source policy"); @@ -40,9 +52,9 @@ export function resolveSourceRecipients(params: JsonObject): JsonObject { if (!requireStringArray(source.sender_ids, "source senders").includes(sender)) { throw new EffectRuntimeRequestError("source sender is not authorized"); } - const { targets, blocked } = grants(source); + const { targets, blocked, scope } = grants(source); const available = recipients(params.available, "registered recipients", true); - const selected = available.filter(target => granted(target, targets, blocked)); + const selected = available.filter(target => granted(target, targets, blocked, scope)); const unique = new Map(selected.map(row => [JSON.stringify([row.goal_id, row.agent_id]), row])); return { targets: [...unique.values()].sort((a, b) => a.goal_id.localeCompare(b.goal_id) || a.agent_id!.localeCompare(b.agent_id!)) }; @@ -53,7 +65,7 @@ export function resolveSourceRecipients(params: JsonObject): JsonObject { */ export function configureSourceRecipient(params: JsonObject): JsonObject { const source = requireJsonObject(params.source, "source policy"); - const { targets, blocked } = grants(source); + const { targets, blocked, scope } = grants(source); const goal_id = requireNonEmptyString(params.goal_id, "delivery target Goal"); const agent_id = params.agent_id === null || params.agent_id === undefined ? undefined : requireNonEmptyString(params.agent_id, "delivery target Agent"); @@ -70,7 +82,7 @@ export function configureSourceRecipient(params: JsonObject): JsonObject { !available.some(row => matches(target, row)))) { throw new EffectRuntimeRequestError("delivery target must be a registered Agent or all Agents in an active Goal"); } - if (Object.hasOwn(source, "evidence_goal_ids")) { + if (scope === "selected" && Object.hasOwn(source, "evidence_goal_ids")) { const readGoals = requireStringArray(source.evidence_goal_ids, "source read Goals"); if (readGoals.some(id => !/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(id)) || !readGoals.includes(goal_id)) { throw new EffectRuntimeRequestError("target Goal is outside the channel read scope"); @@ -78,33 +90,42 @@ export function configureSourceRecipient(params: JsonObject): JsonObject { } } const before = agent_id === undefined - ? targets.some(row => row.goal_id === goal_id && row.agent_id === undefined) - : granted(target, targets, blocked); + ? (scope === "all_registered" || targets.some(row => row.goal_id === goal_id && row.agent_id === undefined)) && + !blocked.some(row => row.goal_id === goal_id && row.agent_id === undefined) + : granted(target, targets, blocked, scope); let updatedTargets = targets; let updatedBlocked = blocked; if (grant) { - if (!targets.some(row => matches(row, target))) updatedTargets = [...targets, target]; - if (agent_id !== undefined) updatedBlocked = blocked.filter(row => !matches(target, row)); + if (agent_id !== undefined && blocked.some(row => row.goal_id === goal_id && row.agent_id === undefined)) { + throw new EffectRuntimeRequestError("restore the Goal delivery grant before restoring an individual Agent"); + } + if (scope === "selected" && !targets.some(row => matches(row, target))) updatedTargets = [...targets, target]; + // Regrant only this scope; a Goal regrant preserves individual revocations. + updatedBlocked = blocked.filter(row => !(row.goal_id === goal_id && row.agent_id === agent_id)); } else if (agent_id === undefined) { - // Revoking a Goal also revokes individually enrolled members of that Goal. updatedTargets = targets.filter(row => row.goal_id !== goal_id); - updatedBlocked = blocked.filter(row => row.goal_id !== goal_id); + if (scope === "all_registered" && !blocked.some(row => row.goal_id === goal_id && row.agent_id === undefined)) { + updatedBlocked = [...blocked, target]; + } + // Preserve individual exceptions when the Goal is restored later. } else { updatedTargets = targets.filter(row => !matches(target, row)); - if (updatedTargets.some(row => matches(row, target)) && !blocked.some(row => matches(row, target))) { + // Record the exact revocation even if a Goal block or missing enrollment + // already disables delivery. Restoring that broader scope must not erase it. + if (!blocked.some(row => row.goal_id === goal_id && row.agent_id === agent_id)) { updatedBlocked = [...blocked, target]; } } const changed = JSON.stringify(updatedTargets) !== JSON.stringify(targets) || JSON.stringify(updatedBlocked) !== JSON.stringify(blocked); - // Preserve provider metadata when the semantic recipient set is unchanged. + // Preserve provider metadata when the declared grants and exceptions are unchanged. const updatedSource: JsonObject = { ...source }; if (changed) { updatedSource.targets = updatedTargets; if (updatedBlocked.length) updatedSource.blocked_targets = updatedBlocked; else delete updatedSource.blocked_targets; } - return { source: updatedSource, target, would_change: changed, + return { source: updatedSource, target, local_delivery_scope: scope, would_change: changed, granted_before: before, granted_after: grant, existing_target_count: targets.length, resulting_target_count: updatedTargets.length, includes_future_agents: agent_id === undefined && grant }; diff --git a/tests/control_plane_ts/source_grants.test.ts b/tests/control_plane_ts/source_grants.test.ts index e9afbc3cb5..39e0833bf6 100644 --- a/tests/control_plane_ts/source_grants.test.ts +++ b/tests/control_plane_ts/source_grants.test.ts @@ -6,9 +6,9 @@ const worker = { goal_id: "research", agent_id: "worker" }; const peer = { goal_id: "research", agent_id: "peer" }; const other = { goal_id: "other", agent_id: "worker" }; const available = [worker, peer, other]; -const source = { sender_ids: ["owner"], targets: [{ goal_id: "research" }] }; +const source = { local_delivery_scope: "selected", sender_ids: ["owner"], targets: [{ goal_id: "research" }] }; -test("a managed Goal includes current and future registered Agents, never another Goal", () => { +test("a selected managed Goal includes current and future registered Agents, never another Goal", () => { assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source, available: [worker, other] }), { targets: [worker] }); assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source, available }), { targets: [peer, worker] }); assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source, available: [] }), { targets: [] }); @@ -45,14 +45,14 @@ test("operator configuration requires an existing sender, active membership and { source: { ...source, evidence_goal_ids: "research" } }, ]) assert.throws(() => configureSourceRecipient({ ...params, ...change })); // Read access alone never becomes a delegation grant. - assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: { sender_ids: ["owner"], evidence_goal_ids: ["research"] }, available }), { targets: [] }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: { local_delivery_scope: "selected", sender_ids: ["owner"], evidence_goal_ids: ["research"] }, available }), { targets: [] }); }); test("malformed optional Agent identity cannot widen a grant to the whole Goal", () => { for (const bad of [null, "", 7, false]) { assert.throws(() => resolveSourceRecipients({ sender_id: "owner", source: { ...source, targets: [{ goal_id: "research", agent_id: bad }] }, available })); } - for (const bad of [null, {}, [null], [{ goal_id: "research" }]]) { + for (const bad of [null, {}, [null], [{ agent_id: "worker" }]]) { assert.throws(() => resolveSourceRecipients({ sender_id: "owner", source: { ...source, blocked_targets: bad }, available })); } for (const change of [{ sender_id: "another-person" }, { sender_id: "" }, @@ -60,3 +60,51 @@ test("malformed optional Agent identity cannot widen a grant to the whole Goal", assert.throws(() => resolveSourceRecipients({ sender_id: "owner", source, available, ...change })); } }); + + +test("sender-bound source defaults to every registered local recipient, including new Goals", () => { + const policy = { sender_ids: ["owner"] }; + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: policy, available: [worker] }), { targets: [worker] }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: policy, available }), { targets: [other, peer, worker] }); + // Existing enrollment does not narrow the new default. Selected mode is explicit. + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: { ...source, local_delivery_scope: "all_registered" }, available }), { targets: [other, peer, worker] }); + for (const value of [null, "", "all", false]) assert.throws(() => resolveSourceRecipients({ sender_id: "owner", source: { ...source, local_delivery_scope: value }, available })); + assert.throws(() => resolveSourceRecipients({ sender_id: "visitor", source: policy, available })); + assert.throws(() => resolveSourceRecipients({ sender_id: "owner", source: { evidence_goal_ids: ["research"] }, available })); +}); + +test("default local access keeps exact and Goal revocations across future registrations", () => { + const policy = { sender_ids: ["owner"] }; + const params = { source: policy, goal_id: "research", agent_id: "peer", available, active_goal_ids: ["research", "other"], grant: false }; + const revoked = configureSourceRecipient(params); + assert.equal(revoked.granted_before, true); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: revoked.source, available }), { targets: [other, worker] }); + const renewed = configureSourceRecipient({ ...params, source: revoked.source, agent_id: null, grant: true }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: renewed.source, available }), { targets: [other, worker] }); + const blockedGoal = configureSourceRecipient({ ...params, source: renewed.source, agent_id: null }); + const newcomer = { goal_id: "research", agent_id: "newcomer" }; + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: blockedGoal.source, available: [...available, newcomer] }), { targets: [other] }); + assert.equal(configureSourceRecipient({ ...params, source: blockedGoal.source, agent_id: null }).would_change, false); + const restoredGoal = configureSourceRecipient({ ...params, source: blockedGoal.source, agent_id: null, grant: true }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: restoredGoal.source, available: [...available, newcomer] }), { targets: [other, newcomer, worker] }); + const restoredAgent = configureSourceRecipient({ ...params, source: restoredGoal.source, grant: true }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: restoredAgent.source, available }), { targets: [other, peer, worker] }); +}); + +test("an Agent revocation while its Goal is disabled survives Goal restoration", () => { + for (const policy of [{ sender_ids: ["owner"] }, source]) { + const params = { source: policy, goal_id: "research", agent_id: null, available, + active_goal_ids: ["research", "other"], grant: false }; + const disabledGoal = configureSourceRecipient(params); + const disabledAgent = configureSourceRecipient({ ...params, source: disabledGoal.source, agent_id: "peer" }); + assert.equal(disabledAgent.granted_before, false); + assert.equal(disabledAgent.would_change, true); + assert.equal(configureSourceRecipient({ ...params, source: disabledAgent.source, agent_id: "peer" }).would_change, false); + const restoredGoal = configureSourceRecipient({ ...params, source: disabledAgent.source, grant: true }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: restoredGoal.source, available }), + { targets: policy === source ? [worker] : [other, worker] }); + const restoredAgent = configureSourceRecipient({ ...params, source: restoredGoal.source, agent_id: "peer", grant: true }); + assert.deepEqual(resolveSourceRecipients({ sender_id: "owner", source: restoredAgent.source, available }), + { targets: policy === source ? [peer, worker] : [other, peer, worker] }); + } +}); diff --git a/tests/extensions/test_lark_manager_returns.py b/tests/extensions/test_lark_manager_returns.py index 316707588f..31b042a18c 100644 --- a/tests/extensions/test_lark_manager_returns.py +++ b/tests/extensions/test_lark_manager_returns.py @@ -70,7 +70,7 @@ def test_original_source_reply_waits_for_ack_and_rechecks_authority( policy = { "schema_version": POLICY_SCHEMA, "sources": { - session["channel_id"]: {"sender_ids": ["owner"], "targets": [target]} + session["channel_id"]: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [target]} }, } _write(_root(root) / "policy.json", policy) diff --git a/tests/extensions/test_lark_reply_handoff.py b/tests/extensions/test_lark_reply_handoff.py index 307ed91d4e..132e05450c 100644 --- a/tests/extensions/test_lark_reply_handoff.py +++ b/tests/extensions/test_lark_reply_handoff.py @@ -59,7 +59,7 @@ def test_short_reply_handoff_preserves_source_and_returns_once( ) _write(_root(tmp_path) / "policy.json", { "schema_version": POLICY_SCHEMA, - "sources": {session["channel_id"]: {"sender_ids": ["owner"], "targets": [target]}}, + "sources": {session["channel_id"]: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [target]}}, }) parent_text = "Public cash-flow draft: separate cash payments from finance leases." quoted = {"message_id": "om_parent", "conversation_id": "room", "content": parent_text} diff --git a/tests/test_manager_context_handoff.py b/tests/test_manager_context_handoff.py index 24917474b9..ddb6e24693 100644 --- a/tests/test_manager_context_handoff.py +++ b/tests/test_manager_context_handoff.py @@ -130,7 +130,8 @@ def test_stopped_goal_is_not_a_context_recipient_and_revokes_replay(fixture): } -def test_stopped_or_invalid_goal_is_excluded_from_lark_and_goal_chat(fixture): +@pytest.mark.parametrize("local_scope", ["selected", "all_registered"]) +def test_stopped_or_invalid_goal_is_excluded_from_lark_and_goal_chat(fixture, local_scope): root, registry, session, turn, request = fixture data = json.loads(registry.read_text()) data["goals"][0]["activation"] = { @@ -148,14 +149,15 @@ def test_stopped_or_invalid_goal_is_excluded_from_lark_and_goal_chat(fixture): _write(_root(root) / "policy.json", { "schema_version": POLICY_SCHEMA, "sources": {lark_session["channel_id"]: { - "sender_ids": ["owner"], "targets": [request] + "local_delivery_scope": local_scope, "sender_ids": ["owner"], "targets": [request] }}, }) register_ingress(root, session_id=session["session_id"], client_turn_id=turn["client_turn_id"], channel=lark_session["channel_id"], sender_id="owner", message=turn["message"], source_id="lark:original") - assert authority(root, registry, lark_session, lark_turn)["targets"] == [] + expected = [] if local_scope == "selected" else [{"goal_id": "other", "agent_id": "peer"}] + assert authority(root, registry, lark_session, lark_turn)["targets"] == expected with pytest.raises(ValueError, match="not authorized"): deliver(root, registry, session=lark_session, turn=lark_turn, request=request) @@ -219,7 +221,7 @@ def test_original_context_delivery_is_idempotent_without_priority_or_todo_writes ) -def test_external_authority_requires_exact_sender_source_and_recipient(fixture): +def test_selected_external_authority_requires_exact_sender_source_and_recipient(fixture): root, registry, session, turn, request = fixture session["channel_id"] = "manager.external.group" turn["origin"] = "lark" @@ -228,7 +230,7 @@ def test_external_authority_requires_exact_sender_source_and_recipient(fixture): { "schema_version": POLICY_SCHEMA, "sources": { - session["channel_id"]: {"sender_ids": ["owner"], "targets": [request]} + session["channel_id"]: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [request]} }, }, ) @@ -275,7 +277,7 @@ def test_operator_delivery_target_preview_grant_revoke_and_live_authority(fixtur _write(policy_path, { "schema_version": POLICY_SCHEMA, "sources": {channel: { - "sender_ids": ["owner"], "targets": [other], + "local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [other], "evidence_goal_ids": ["research", "other"], "evidence_ssh_hosts": {"example-host": ["research"]}, }}, @@ -316,6 +318,9 @@ def test_operator_delivery_target_preview_grant_revoke_and_live_authority(fixtur )["changed"] # Older policy rows may carry metadata; recipient identity is still the pair. + assert configure_delivery_target( + root, registry, channel=channel, **request, grant=True, execute=True + )["changed"] saved = json.loads(policy_path.read_text()) saved["sources"][channel]["targets"] = [other, {**request, "note": "legacy"}, request] _write(policy_path, saved) @@ -336,7 +341,7 @@ def test_operator_target_grant_fails_closed_without_audited_source_or_agent(fixt configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True) policy_path = _root(root) / "policy.json" - source = {"sender_ids": ["owner"], "evidence_goal_ids": ["other"], "targets": []} + source = {"local_delivery_scope": "selected", "sender_ids": ["owner"], "evidence_goal_ids": ["other"], "targets": []} _write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: source}}) with pytest.raises(ValueError, match="outside the channel read scope"): configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True) @@ -366,7 +371,7 @@ def test_manager_inbox_cli_previews_and_applies_delivery_scope(fixture, whole_go policy_path = _root(root) / "policy.json" _write(policy_path, { "schema_version": POLICY_SCHEMA, - "sources": {channel: {"sender_ids": ["owner"], "targets": []}}, + "sources": {channel: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": []}}, }) base = [ sys.executable, "-m", "loopx.cli", "--registry", str(registry), @@ -398,7 +403,7 @@ def test_goal_delivery_grant_inherits_agents_and_rechecks_specific_revocation(fi turn["origin"] = "lark" policy_path = _root(root) / "policy.json" _write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: { - "sender_ids": ["owner"], "targets": [{"goal_id": "research"}], + "local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [{"goal_id": "research"}], }}}) register_ingress(root, session_id=session["session_id"], client_turn_id=turn["client_turn_id"], channel=channel, sender_id="owner", message=turn["message"], source_id="lark:original") @@ -426,6 +431,34 @@ def test_goal_delivery_grant_inherits_agents_and_rechecks_specific_revocation(fi assert authority(root, registry, session, turn)["targets"] == [] +@pytest.mark.parametrize("local_scope", ["selected", "all_registered"]) +def test_agent_revoke_during_goal_revocation_still_denies_original_replay(fixture, local_scope): + root, registry, session, turn, request = fixture + channel = "manager.external." + "f" * 24 + session["channel_id"] = channel + turn["origin"] = "lark" + policy_path = _root(root) / "policy.json" + _write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {channel: { + "local_delivery_scope": local_scope, "sender_ids": ["owner"], + "targets": [{"goal_id": "research"}], + }}}) + register_ingress(root, session_id=session["session_id"], client_turn_id=turn["client_turn_id"], + channel=channel, sender_id="owner", message=turn["message"], source_id="lark:original") + receipt = deliver(root, registry, session=session, turn=turn, request=request) + configure_delivery_target(root, registry, channel=channel, goal_id="research", grant=False, execute=True) + policy_before_preview = policy_path.read_bytes() + preview = configure_delivery_target(root, registry, channel=channel, **request, grant=False) + assert preview["would_change"] and not preview["granted_before"] + assert policy_path.read_bytes() == policy_before_preview + assert configure_delivery_target(root, registry, channel=channel, **request, grant=False, execute=True)["changed"] + configure_delivery_target(root, registry, channel=channel, goal_id="research", grant=True, execute=True) + assert request not in authority(root, registry, session, turn)["targets"] + with pytest.raises(ValueError, match="not authorized"): + deliver(root, registry, session=session, turn=turn, request=request) + configure_delivery_target(root, registry, channel=channel, **request, grant=True, execute=True) + assert deliver(root, registry, session=session, turn=turn, request=request)["request_id"] == receipt["request_id"] + + def test_same_goal_recipients_keep_inboxes_and_decisions_separate(fixture): root, registry, session, turn, request = fixture data = json.loads(registry.read_text()) @@ -630,7 +663,7 @@ def test_provider_wrapper_is_not_forwarded_and_large_registry_is_supported(fixtu { "schema_version": POLICY_SCHEMA, "sources": { - session["channel_id"]: {"sender_ids": ["owner"], "targets": [request]} + session["channel_id"]: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [request]} }, }, ) @@ -662,7 +695,7 @@ def test_lark_bridge_registers_provenance_before_queueing(fixture): { "schema_version": POLICY_SCHEMA, "sources": { - session["channel_id"]: {"sender_ids": ["owner"], "targets": [request]} + session["channel_id"]: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [request]} }, }, ) @@ -703,3 +736,37 @@ def wait_for_turn(self, **_kw): assert ( pending(root, "research", "worker")["items"][0]["message"] == "Original intent" ) + + +def test_sender_bound_default_delivers_across_goals_and_new_registration(fixture): + root, registry, session, turn, target = fixture + channel = "manager.external." + "d" * 24 + session = {**session, "channel_id": channel} + turn = {**turn, "origin": "lark"} + policy_path = _root(root) / "policy.json" + _write(policy_path, {"schema_version": POLICY_SCHEMA, + "sources": {channel: {"sender_ids": ["owner"]}}}) + register_ingress(root, session_id=session["session_id"], client_turn_id=turn["client_turn_id"], + channel=channel, sender_id="owner", message=turn["message"], source_id="lark:default-request") + other = {"goal_id": "other", "agent_id": "peer"} + assert authority(root, registry, session, turn)["targets"] == [other, target] + receipt = deliver(root, registry, session=session, turn=turn, request=other) + assert receipt["status"] == "delivered" + assert pending(root, "other", "peer")["items"][0]["message"] == turn["message"] + data = json.loads(registry.read_text()) + data["goals"].append({"id": "new-goal", "repo": str(root), + "coordination": {"registered_agents": ["new-worker"]}}) + registry.write_text(json.dumps(data)) + newcomer = {"goal_id": "new-goal", "agent_id": "new-worker"} + assert newcomer in authority(root, registry, session, turn)["targets"] + original = policy_path.read_bytes() + preview = configure_delivery_target(root, registry, channel=channel, **other, grant=False) + assert preview["granted_before"] and preview["would_change"] + assert policy_path.read_bytes() == original + configure_delivery_target(root, registry, channel=channel, **other, grant=False, execute=True) + with pytest.raises(ValueError, match="not authorized"): + deliver(root, registry, session=session, turn=turn, request=other) + configure_delivery_target(root, registry, channel=channel, goal_id="other", grant=True, execute=True) + assert other not in authority(root, registry, session, turn)["targets"] + configure_delivery_target(root, registry, channel=channel, **other, grant=True, execute=True) + assert deliver(root, registry, session=session, turn=turn, request=other)["request_id"] == receipt["request_id"] diff --git a/tests/test_manager_context_roundtrip.py b/tests/test_manager_context_roundtrip.py index 64939a1cfd..362a61b730 100644 --- a/tests/test_manager_context_roundtrip.py +++ b/tests/test_manager_context_roundtrip.py @@ -69,7 +69,7 @@ def create(external=False, project=False, brief=None): "schema_version": POLICY_SCHEMA, "sources": { session["channel_id"]: { - "sender_ids": ["owner"], + "local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [target], } }, diff --git a/tests/test_manager_context_tracking.py b/tests/test_manager_context_tracking.py index 8ef594100b..d0f72c5ca9 100644 --- a/tests/test_manager_context_tracking.py +++ b/tests/test_manager_context_tracking.py @@ -98,7 +98,7 @@ def test_external_query_is_exact_audience_not_just_goal(fixture): _root(root) / "policy.json", { "schema_version": POLICY_SCHEMA, - "sources": {channel: {"sender_ids": ["owner"], "targets": [target]}}, + "sources": {channel: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [target]}}, }, ) register_ingress( @@ -217,7 +217,7 @@ def test_external_handoff_keeps_links_without_reading_receiver_private_work(fixt incoming = dict(turn, origin="lark") _write(_root(root) / "policy.json", { "schema_version": POLICY_SCHEMA, - "sources": {channel: {"sender_ids": ["owner"], "targets": [target]}}, + "sources": {channel: {"local_delivery_scope": "selected", "sender_ids": ["owner"], "targets": [target]}}, }) register_ingress(root, session_id=external["session_id"], client_turn_id=turn["client_turn_id"], channel=channel, diff --git a/tests/test_peer_collaboration.py b/tests/test_peer_collaboration.py index 2ca475a600..79d2ea58a1 100644 --- a/tests/test_peer_collaboration.py +++ b/tests/test_peer_collaboration.py @@ -146,7 +146,7 @@ def external_scenario(scenario): _write(policy_path, { "schema_version": POLICY_SCHEMA, "sources": {session["channel_id"]: { - "sender_ids": ["fixture-owner"], + "local_delivery_scope": "selected", "sender_ids": ["fixture-owner"], "targets": [{"goal_id": "delivery", "agent_id": agent} for agent in ("builder", "reviewer")], }}, @@ -164,13 +164,16 @@ def external_scenario(scenario): return root, registry, brief, store, session, turn, receipt["request_id"] -@pytest.mark.parametrize("whole_goal", [False, True]) -def test_granted_external_peer_request_returns_through_original_conversation(external_scenario, whole_goal): +@pytest.mark.parametrize("scope", ["selected_agent", "selected_goal", "all_registered"]) +def test_granted_external_peer_request_returns_through_original_conversation(external_scenario, scope): root, registry, brief, store, session, turn, parent = external_scenario - if whole_goal: + if scope != "selected_agent": policy_path = _root(root) / "policy.json" policy = json.loads(policy_path.read_text()) policy["sources"][session["channel_id"]]["targets"] = [{"goal_id": "delivery"}] + if scope == "all_registered": + del policy["sources"][session["channel_id"]]["local_delivery_scope"] + del policy["sources"][session["channel_id"]]["targets"] _write(policy_path, policy) path = root / "external-review.json" path.write_text(json.dumps(brief)) @@ -784,3 +787,20 @@ def test_peer_binary_artifact_preserves_crlf_and_ctrl_z_digest(scenario): assert readiness["status"] == "available" assert readiness["observed_sha256"] == readiness["expected_sha256"] == digest assert readiness["content_supplied"] is False + + +def test_default_local_forwarding_keeps_revocations_after_original_delivery(external_scenario): + root, registry, brief, _store, session, _turn, parent = external_scenario + policy_path = _root(root) / "policy.json" + policy = json.loads(policy_path.read_text()) + source = policy["sources"][session["channel_id"]] + source.pop("local_delivery_scope") + source.pop("targets") + _write(policy_path, policy) + first = request(root, registry, "delivery", "builder", "analyst", "default-hop", brief, parent) + assert first["request_id"] + # The source's current exceptions also protect later hops, not only Chat. + policy["sources"][session["channel_id"]]["blocked_targets"] = [{"goal_id": "delivery", "agent_id": "reviewer"}] + _write(policy_path, policy) + with pytest.raises(ValueError, match="reviewer is not authorized"): + request(root, registry, "delivery", "analyst", "reviewer", "blocked-hop", brief, first["request_id"])