From cab480d49c0f987a8e93318518306b661163ce14 Mon Sep 17 00:00:00 2001 From: TKCen <5960303+TKCen@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:58:44 +0200 Subject: [PATCH 01/16] Use producer-owned material identity for attention replay --- AGENTS.md | 37 ++- README.md | 6 +- agent_sensorium/admission.py | 153 +++++++-- agent_sensorium/admission_index.py | 64 +++- agent_sensorium/attention.py | 314 +++++++++++++++++- agent_sensorium/gate.py | 2 +- agent_sensorium/tools.py | 90 ++--- docs/README.md | 34 +- docs/attention-and-workroom/architecture.md | 100 ++++++ docs/attention-and-workroom/change-scope.md | 53 +++ .../design-rationale.md | 45 +++ docs/attention-and-workroom/migration.md | 53 +++ skills/agent-sensorium/SKILL.md | 11 +- ..._attention_lifecycle_review_regressions.py | 19 ++ tests/test_cooldown_subject_signature.py | 192 ++++++----- tests/test_core_cache_injection.py | 265 +++++++++++++++ tests/test_core_latent_awareness.py | 113 +++++++ tests/test_core_material_replay.py | 202 +++++++++++ tests/test_feedback.py | 28 +- tests/test_pointer_doorway_saved_residue.py | 206 +++++------- tests/test_probe_audit.py | 6 +- tests/test_prospective_owner_matrix_tools.py | 16 +- tests/test_status_reference_id.py | 128 ++++--- tests/test_tools.py | 12 + 24 files changed, 1737 insertions(+), 412 deletions(-) create mode 100644 docs/attention-and-workroom/architecture.md create mode 100644 docs/attention-and-workroom/change-scope.md create mode 100644 docs/attention-and-workroom/design-rationale.md create mode 100644 docs/attention-and-workroom/migration.md create mode 100644 tests/test_core_cache_injection.py create mode 100644 tests/test_core_latent_awareness.py create mode 100644 tests/test_core_material_replay.py diff --git a/AGENTS.md b/AGENTS.md index 7d48b59..1ee6dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,32 @@ -# Agent Sensorium Development Notes +# Agent Sensorium development -This repo is the development source for the Agent Sensorium Hermes plugin. +Develop in an isolated checkout. Installed plugin code, private configuration, bindings, accounting and runtime records are separate owners and must remain untouched unless an operator separately authorizes adoption. -## Guardrails +## Scope and safeguards -- Build in this repo first; do not edit `~/.hermes/plugins/agent-sensorium/` directly unless installing a tested snapshot. -- MVP must remain pull-based and local-only. -- **Repair posture:** a host agent who is patching the Sensorium plugin itself (in this repo or in the live `~/.hermes/plugins/agent-sensorium/` install) must do it via an async worker lane unless the change is ≤3 tool calls, reversible, and mostly judgment/synthesis. See the `agent-sensorium` bundled skill's *Repair posture* section and `lead-orchestrator-posture` § *Sensorium repair preflight*. -- No proactive Discord/DM delivery in MVP. -- No model-backed Subconscious pass until the deterministic spine is verified. -- Runtime state must live outside the repo unless tests use temp dirs. -- Keep enhancement proposals separate from accepted MVP scope. +- Keep ordinary Sensorium pull-based and local-first; no unsolicited outbound delivery or external task creation. +- Validate canonical instance/owner and closed sensitivity/surface boundaries before effects. Preserve exact leases, source replay, presentation and source-bound outbox owners. +- Advisory confidence routes attention; it cannot authorize durable memory, tasks, artifacts or delivery. Model-backed advisory stays explicitly opt-in. +- Runtime state belongs outside reusable source; tests use isolated synthetic stores. Never embed real identities, conversations, host locations, account configuration or private receipts in source, fixtures, docs or packages. Preserve public license attribution. +- Bound changes by file ownership. For substantial repair, use an isolated worker/worktree and independent review; do not overwrite another contributor's work, require a private orchestration framework or patch live state during diagnosis. +- Keep proposed behavior separate from implemented guarantees. Do not install clocks/services, migrate real accounting, run providers or publish as an incidental development step. + +## Documentation and status + +Read [architecture](docs/attention-and-workroom/architecture.md), [rationale](docs/attention-and-workroom/design-rationale.md), [migration](docs/attention-and-workroom/migration.md) and [change scope](docs/attention-and-workroom/change-scope.md). Producer material replay, multi-record bounded progress and optional latent metadata are implemented with focused regressions and independent source review; bind complete-suite and privacy evidence to the exact source before adoption. Native/workroom/owner/transport are proposals, not a synthetic substitute framework or live capability proof. Request-time quota, failed-read preconsumption, journal/migration, lifetime continuation and whole-tree/active cancellation are proposed strengthenings. Gateway compatibility and Wiki security remain held. + +The [bundled skill](skills/agent-sensorium/SKILL.md#development-and-repair) separates ordinary use, admin and repair. The [documentation index](docs/README.md) links existing canonical contracts. ## Verification -Run before committing code changes: +Prefer small reviewable changes and tests first for pure store/gate/dispatcher logic. Keep core stdlib-only and handlers directly callable without a live host. Use synthetic offline fakes for proposed native/provider/process contracts; actual model, memory and service-manager proofs need separate authorization. + +Development commands (not an execution receipt): ```bash +python -m pip install -e ".[test]" python -m pytest tests -q python -m py_compile agent_sensorium/*.py scripts/*.py ``` -## Implementation style - -- Small commits per phase. -- Tests first for pure store/gate/dispatcher logic. -- Prefer stdlib-only for MVP. -- Tool handlers must be callable directly in tests without a live Hermes runtime. +Run relevant checks and a complete suite against the frozen source before acceptance. Preserve failures/skips as evidence, not automatic fixture obsolescence. Validate relative documentation links and inspect the full source and built distributions for privacy separately. Installation, real migration, activation, commits and publication require their own authority; a focused green test set is not release readiness. diff --git a/README.md b/README.md index 50a4372..242e5a1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ An environment-reactive attention substrate for Hermes agents. Agent Sensorium c **Authority boundary.** Agent Sensorium is an attention substrate, not an outbound automation switch. It does not send messages, create tasks, or reach out on any platform without explicit operator configuration and a conscious-tier receipt from the agent. Sensitivity tuning, surface gating, and action policies are recorded in operator-controlled configuration files so the runtime boundary stays auditable. -**Repair posture (host-agent note).** Sensorium is local code, but a host agent repairing its own attention substrate from the foreground is a self-referential hazard: the very attention it is using to fix the substrate gets re-shaped mid-repair. Host agents and their leads must apply the delegate-vs-foreground gate from `lead-orchestrator-posture` before patching anything in this repo, the installed `~/.hermes/plugins/agent-sensorium/` tree, or the gateway/cron that drives it. The bundled `agent-sensorium` skill has the *Repair posture* section. Inline repair is reserved for ≤3 tool-call reversible judgment/synthesis slices. +**Development and repair.** Work in an isolated checkout, preserve installed runtime and state, and validate a frozen snapshot before any separately authorized installation. Keep diagnosis, source edits and live adoption separate. For substantial repairs, use a bounded worker or worktree with explicit file ownership and independent review; no particular agent framework is required. See the [bundled skill](skills/agent-sensorium/SKILL.md#development-and-repair) and [developer instructions](AGENTS.md). + +**Implementation status.** Producer-owned material replay/coalescing, multi-record bounded index progress and optional read-only held/dormant metadata are implemented with synthetic regression coverage and independent source review. Native sessions, finite workrooms, persistent accounting and choice transport are **proposed**, not installed features. Gateway compatibility and Wiki security remain held. Exact-source test and package/privacy receipts, host integration, installation and publication are separate gates; this documentation is not a release or deployment receipt. See [architecture](docs/attention-and-workroom/architecture.md), [rationale](docs/attention-and-workroom/design-rationale.md), [migration](docs/attention-and-workroom/migration.md) and [complete change scope](docs/attention-and-workroom/change-scope.md). --- @@ -46,7 +48,7 @@ Core pieces: - **Plugin registration (`agent_sensorium/plugin.py`)** — registers one live tool, the admin toolset, two `pre_llm_call` hooks, and the bundled skill. - **Profile state (`agent_sensorium/store.py`, `config.py`)** — stores signals, events, candidates, threads, decisions, artifacts, and per-profile config under `~/.hermes/agent-sensorium//`. -- **Gate and promotion pipeline (`gate.py`, `attention.py`, `threads.py`)** — normalizes salience, applies sensitivity/surface policy, and builds candidate/thread state. +- **Gate and promotion pipeline (`gate.py`, `attention.py`, `tools.py`)** — normalizes salience, applies sensitivity/surface policy, and builds candidate/thread state. - **Memory Volunteering Protocol (`docs/memory-volunteering-protocol.md`)** — formalizes the evidence-cited capsule → transparent confidence proposal → Conscious authorization sequence for any Subconscious/Sensorium path that wants to volunteer memory, insight, or offer candidates. - **Pre-LLM hooks (`pointers.py`, `pre_llm_salience.py`)** — inject compact reminders/pointers before model calls without mutating state. - **Deterministic tick scripts (`scripts/sensorium_tick.py`)** — run heartbeat/pressure sensors, compaction, and thread service from cron or manual smoke tests. diff --git a/agent_sensorium/admission.py b/agent_sensorium/admission.py index f7939d0..9c3cc4f 100644 --- a/agent_sensorium/admission.py +++ b/agent_sensorium/admission.py @@ -40,13 +40,7 @@ def source_candidate_fingerprint(candidate: dict) -> str: def _claim_for_signal(instance: str, signal: dict) -> tuple[dict | None, str | None]: - if "_admission_claim" in signal or "_admission_error" in signal: - claim = signal.get("_admission_claim") - error = signal.get("_admission_error") - return ( - claim if isinstance(claim, dict) else None, - error if isinstance(error, str) else None, - ) + """Parse canonical producer fields only, never serialized projection keys.""" sensor, sensor_ok = _component(signal.get("sensor")) source, source_ok = _component(signal.get("source")) if not sensor_ok or not source_ok: @@ -95,12 +89,23 @@ def _claim_for_signal(instance: str, signal: dict) -> tuple[dict | None, str | N return None, "malformed_artifact_meta" meta = meta if isinstance(meta, dict) else {} - if "entry_id" in meta or "sha256" in meta: + has_research_identity = "source_id" in meta or "item_id" in meta + if "entry_id" in meta and has_research_identity: + return None, "ambiguous_source_identity" + + if "entry_id" in meta or ("sha256" in meta and not has_research_identity): entry_id, entry_ok = _component(meta.get("entry_id")) revision, revision_ok = _component(meta.get("sha256")) if not entry_ok or not revision_ok: return None, "malformed_frontier_identity" assert entry_id is not None and revision is not None + aliases = [signal["source_revision"]] if "source_revision" in signal else [] + if "revision_id" in meta: + aliases.append(meta["revision_id"]) + if any(not _component(value)[1] for value in aliases): + return None, "malformed_frontier_revision" + if any(value != revision for value in aliases): + return None, "conflicting_frontier_revision" item_values = [instance, sensor, source, "frontier", entry_id] return { "item_key": _opaque("item", item_values), @@ -109,18 +114,47 @@ def _claim_for_signal(instance: str, signal: dict) -> tuple[dict | None, str | N "evidence_class": "source_owned", }, None - if "source_id" in meta or "item_id" in meta: + if has_research_identity: source_id, source_id_ok = _component(meta.get("source_id")) item_id, item_id_ok = _component(meta.get("item_id")) if not source_id_ok or not item_id_ok: return None, "malformed_research_identity" assert source_id is not None and item_id is not None + revision_claims = [ + value for present, value in ( + ("source_revision" in signal, signal.get("source_revision")), + ("revision_id" in meta, meta.get("revision_id")), + ("sha256" in meta, meta.get("sha256")), + ) if present + ] + if revision_claims: + revisions = [_component(value) for value in revision_claims] + if any(not valid for _, valid in revisions): + return None, "malformed_research_revision" + revision_values = {value for value, _ in revisions} + if len(revision_values) != 1: + return None, "conflicting_research_revision" + revision = next(iter(revision_values)) + else: + # Missing producer revision means uncertain material freshness, not + # a new revision on each observation or view. + revision = "unversioned" + assert revision is not None item_values = [instance, sensor, source, "research_feed", source_id, item_id] + revision_values_for_key = ( + [*item_values, "material_revision", revision] + if revision_claims else [*item_values, "unversioned"] + ) return { "item_key": _opaque("item", item_values), - "revision_key": _opaque("revision", [*item_values, "unversioned"]), + "revision_key": _opaque("revision", revision_values_for_key), "identity_mode": "source", "evidence_class": "source_owned", + # Disposable attribution only; base grammar ignored revision aliases. + "_pre_material_admission_key": _opaque("admission", [ + _opaque("item", item_values), + _opaque("revision", [*item_values, "unversioned"]), + ]), }, None transition_fields = any( @@ -157,6 +191,32 @@ def _snapshot(store, *, scan_bytes: int = 4 * 1024 * 1024) -> dict[str, list[dic return view.snapshot +def _snapshot_claim(instance: str, signal: dict, snap: dict) -> tuple[dict | None, str | None]: + from .admission_index import _AdmissionSnapshot + + if isinstance(snap, _AdmissionSnapshot): + return signal.get("_admission_claim"), signal.get("_admission_error") + return _claim_for_signal(instance, signal) + + +def _legacy_research_admission_key(store, binding: dict, snap: dict) -> str | None: + """Attribute old unversioned disposition only to unchanged source lineage.""" + keys = [] + for signal in snap["signals"]: + claim, error = _snapshot_claim(store.instance, signal, snap) + if not error and claim and claim.get("item_key") == binding.get("item_key"): + keys.append(claim.get("_pre_material_admission_key")) + return keys[-1] if keys and isinstance(keys[-1], str) else None + + +def _same_disposition(prior: dict, binding: dict, legacy_key: str | None) -> bool: + return prior.get("admission_key") == binding["admission_key"] or ( + legacy_key is not None and prior.get("admission_key") == legacy_key + and prior.get("source_candidate_id") == binding["source_candidate_id"] + and prior.get("source_candidate_fingerprint") == binding["source_candidate_fingerprint"] + ) + + def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dict]]) -> tuple[dict | None, str | None]: candidate_id, candidate_ok = _component(candidate.get("id")) if not candidate_ok: @@ -192,7 +252,7 @@ def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dic ] claims: list[dict] = [] for signal in joined: - parsed_claim, error = _claim_for_signal(store.instance, signal) + parsed_claim, error = _snapshot_claim(store.instance, signal, snap) if error: return None, error if parsed_claim: @@ -203,7 +263,7 @@ def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dic for claim in claims } claim: dict = {} - if claims and not dangling and len(claim_identities) == 1: + if claims and len(claims) == len(joined) and not dangling and len(claim_identities) == 1: claim = claims[-1] identity_mode = claim["identity_mode"] evidence_class = claim["evidence_class"] @@ -446,13 +506,14 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup relevant: list[dict] = [] decided = False reason = None + legacy_key = _legacy_research_admission_key(store, binding, snap) for row in snap["decisions"]: prior = row.get("admission_binding") if not isinstance(prior, dict) or prior.get("item_key") != binding["item_key"]: continue relevant.append(_decision_projection(row)) if ( - prior.get("admission_key") == binding["admission_key"] + _same_disposition(prior, binding, legacy_key) and _is_successful_applying_disposition(row) ): decided, reason = True, "prior_disposition" @@ -469,7 +530,7 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup "status": row.get("status"), "admission_key": prior.get("admission_key"), }) - if prior.get("admission_key") == binding["admission_key"]: + if _same_disposition(prior, binding, legacy_key): decided, reason = True, "prior_disposition" elif row.get("status") in _TERMINAL_STATUSES: decided, reason = True, "explicit_item_closure" @@ -703,19 +764,57 @@ def context_for_binding(store, binding: dict, *, source_decisions: list[dict] | } -def event_source_item_key(store, event: dict, *, events: list[dict] | None = None, signals: list[dict] | None = None) -> str | None: - """Return a stable item key for ingest coalescing safeguards when available.""" - del events - signal_index = {row.get("id"): row for row in (signals if signals is not None else store.read_jsonl("signals"))} - claims = [] - for signal_id in event.get("source_signal_ids") or []: +def signal_source_item_identity(instance: str, signal: dict) -> tuple[str, str] | None: + """Return producer-owned (item, material revision) identity when known.""" + claim, error = _claim_for_signal(instance, signal) + if (error or not isinstance(claim, dict) or claim.get("identity_mode") != "source" + or claim.get("evidence_class") not in {"source_owned", "internal_interpretation"}): + return None + item_key = claim.get("item_key") + revision_key = claim.get("revision_key") + if not isinstance(item_key, str) or not isinstance(revision_key, str): + return None + return item_key, revision_key + + +def event_source_item_identity( + store, event: dict, *, signals: list[dict] | None = None, +) -> tuple[str, str] | None: + """Return one unambiguous item/revision claim joined through event signals.""" + rows = signals if signals is not None else store.read_jsonl("signals") + signal_index = { + row.get("id"): row + for row in rows + if isinstance(row, dict) and isinstance(row.get("id"), str) + } + ids = [row.get("id") for row in rows if isinstance(row, dict) and isinstance(row.get("id"), str)] + seen_ids: set[str] = set() + duplicate_ids: set[str] = set() + for identifier in ids: + if identifier in seen_ids: + duplicate_ids.add(identifier) + seen_ids.add(identifier) + signal_ids = event.get("source_signal_ids") or [] + if not isinstance(signal_ids, list) or not signal_ids or any( + not isinstance(identifier, str) or identifier in duplicate_ids for identifier in signal_ids + ): + return None + identities: set[tuple[str, str]] = set() + for signal_id in signal_ids: signal = signal_index.get(signal_id) if not isinstance(signal, dict): - continue - claim, error = _claim_for_signal(store.instance, signal) - if error: return None - if claim: - claims.append(claim) - keys = {claim["item_key"] for claim in claims} - return next(iter(keys)) if len(keys) == 1 else None + identity = signal_source_item_identity(store.instance, signal) + if identity is None: + return None + identities.add(identity) + return next(iter(identities)) if len(identities) == 1 else None + + +def event_source_item_key( + store, event: dict, *, events: list[dict] | None = None, signals: list[dict] | None = None, +) -> str | None: + """Compatibility projection of a uniquely identified event's item key.""" + del events + identity = event_source_item_identity(store, event, signals=signals) + return identity[0] if identity is not None else None diff --git a/agent_sensorium/admission_index.py b/agent_sensorium/admission_index.py index de6908b..72f606d 100644 --- a/agent_sensorium/admission_index.py +++ b/agent_sensorium/admission_index.py @@ -19,7 +19,8 @@ MAX_RECORD_BYTES = 1024 * 1024 BOUNDARY_BYTES = 64 SQLITE_TIMEOUT_SECONDS = 0.2 -SCHEMA_VERSION = 3 +# Identity grammar and raw/projection trust changed; rebuild disposable state. +SCHEMA_VERSION = 4 MAX_DEPENDENT_REFRESHES_PER_PASS = 64 @@ -40,6 +41,10 @@ class AdmissionIndexResult: value: object | None = None +class _AdmissionSnapshot(dict): + """Internal projection boundary; JSON producer payloads cannot preserve it.""" + + def _json(value: object) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":")) @@ -174,13 +179,7 @@ def _connect(self, *, create: bool, readonly: bool = False) -> sqlite3.Connectio @staticmethod def _schema(conn: sqlite3.Connection) -> None: version = int(conn.execute("PRAGMA user_version").fetchone()[0]) - cursor_exists = conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='source_cursor'" - ).fetchone() is not None - legacy_zero = version == 0 and cursor_exists and "next_ordinal" not in { - row[1] for row in conn.execute("PRAGMA table_info(source_cursor)") - } - if version not in {0, SCHEMA_VERSION} or legacy_zero: + if version != SCHEMA_VERSION: # The index is disposable. Rebuild instead of running an unbounded # data migration over stale derived state. conn.executescript(""" @@ -282,8 +281,8 @@ def _schema(conn: sqlite3.Connection) -> None: CREATE TABLE IF NOT EXISTS admission_summary ( key TEXT PRIMARY KEY, value INTEGER NOT NULL ); - PRAGMA user_version=3; """) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") def _source_stat(self, stream: str) -> os.stat_result | None: try: @@ -376,7 +375,7 @@ def _joined_snapshot(self, conn: sqlite3.Connection, candidate: dict) -> dict[st (event_generation, signal_generation, candidate_id), ).fetchall() signals = [json.loads(row[0]) for row in sorted(signal_rows, key=lambda item: item[1])] - return {"signals": signals, "events": events, "candidates": [candidate], "decisions": []} + return _AdmissionSnapshot(signals=signals, events=events, candidates=[candidate], decisions=[]) @staticmethod def _is_applying_decision(projection: dict) -> bool: @@ -496,6 +495,27 @@ def _prior_rows(self, conn: sqlite3.Connection, binding: dict) -> tuple[list[dic "SELECT 1 FROM disposition_evidence WHERE applied=1 AND admission_key=? LIMIT 1", (binding.get("admission_key"),), ).fetchone() + if exact is None: + # Base-era accounting is attributable only to the unchanged + # source representation; fresh r2 cannot inherit settled r1. + from .admission import _legacy_research_admission_key + + source = conn.execute( + "SELECT prompt_projection_json FROM candidate_projection " + "WHERE candidate_id=? AND generation=?", + (binding.get("source_candidate_id"), self._current_generation(conn, "candidates")), + ).fetchone() + legacy_key = None if source is None else _legacy_research_admission_key( + self.store, binding, self._joined_snapshot(conn, json.loads(source[0])), + ) + if legacy_key is not None: + exact = conn.execute( + "SELECT 1 FROM disposition_evidence WHERE applied=1 AND item_key=? " + "AND admission_key=? AND source_candidate_id=? " + "AND source_candidate_fingerprint=? LIMIT 1", + (binding.get("item_key"), legacy_key, binding.get("source_candidate_id"), + binding.get("source_candidate_fingerprint")), + ).fetchone() terminal = conn.execute( "SELECT 1 FROM disposition_evidence WHERE terminal_item_closure=1 " "AND item_key=? LIMIT 1", (binding.get("item_key"),), @@ -825,6 +845,9 @@ def count_vm_step() -> int: elif size == cursor["offset"] and stat and stat.st_mtime_ns != cursor["mtime_ns"]: reset = True elif fd is not None: + if budget < min(int(cursor["offset"]), BOUNDARY_BYTES): + pending_reason = "boundary_validation_budget_exhausted" + break boundary, charged = self._boundary(fd, int(cursor["offset"])) consumed += charged budget -= charged @@ -841,7 +864,7 @@ def count_vm_step() -> int: ordinal = int(cursor["next_ordinal"]) record_count = int(cursor["record_count"]) complete = offset == size - if not complete and budget > BOUNDARY_BYTES and fd is not None: + while not complete and budget > BOUNDARY_BYTES and fd is not None: available = size - offset request = min(available, budget - BOUNDARY_BYTES, MAX_RECORD_BYTES + 1) chunk = os.pread(fd, request, offset) @@ -849,19 +872,25 @@ def count_vm_step() -> int: budget -= len(chunk) final_newline = chunk.rfind(b"\n") if final_newline < 0: - if available > MAX_RECORD_BYTES: + if len(chunk) > MAX_RECORD_BYTES: raise ValueError(f"{stream}:record_too_large") if len(chunk) == available: - raise ValueError(f"{stream}:incomplete_record") + if reset and cursor is not None: + raise ValueError(f"{stream}:incomplete_record") + pending_reason = f"{stream}:incomplete_record" + break # The remaining aggregate budget may have been spent # on earlier streams. Keep the complete-record cursor # unchanged so the next pass can resume safely. pending_reason = f"{stream}:record_waiting_for_larger_remaining_budget" + break else: parsed = chunk[:final_newline + 1] for raw in parsed.splitlines(): if not raw.strip(): continue + if len(raw) > MAX_RECORD_BYTES: + raise ValueError(f"{stream}:record_too_large") value = json.loads(raw) if not isinstance(value, dict): raise ValueError(f"{stream}:non_object_record") @@ -905,7 +934,7 @@ def count_vm_step() -> int: ) conn.commit() progress = self._progress(conn) - complete = not refresh_pending and stable and set(progress) == set(STREAMS) and all( + complete = pending_reason is None and not refresh_pending and stable and set(progress) == set(STREAMS) and all( value["complete"] for value in progress.values() ) conn.set_progress_handler(None, 0) @@ -955,6 +984,11 @@ def _progress(conn: sqlite3.Connection) -> dict[str, dict]: def _ready_connection(self) -> tuple[sqlite3.Connection | None, AdmissionIndexResult | None]: try: conn = self._connect(create=False, readonly=True) + if int(conn.execute("PRAGMA user_version").fetchone()[0]) != SCHEMA_VERSION: + conn.close() + return None, AdmissionIndexResult( + False, "rebuilding", None, {}, 0, "admission_claim_generation_changed", + ) progress = self._progress(conn) if set(progress) != set(STREAMS) or not all(v["complete"] for v in progress.values()): conn.close() @@ -1143,7 +1177,7 @@ def bounded_cacheless_snapshot( """Read a small exact snapshot without creating cache state.""" remaining = max(1, int(scan_bytes)) consumed = records = 0 - snapshot: dict[str, list[dict]] = {stream: [] for stream in STREAMS} + snapshot: dict[str, list[dict]] = _AdmissionSnapshot({stream: [] for stream in STREAMS}) opening = {} try: for stream in STREAMS: diff --git a/agent_sensorium/attention.py b/agent_sensorium/attention.py index 30bdda3..5a00981 100644 --- a/agent_sensorium/attention.py +++ b/agent_sensorium/attention.py @@ -9,11 +9,16 @@ from datetime import datetime, timezone import hashlib import re +import json -from .config import load_instance_config, visible_on_surface -from .schemas import truncate_text, utc_now_iso +from .config import SENSITIVITY_RANK, load_instance_config, visible_on_surface +from .schemas import parse_utc_z_checkpoint, truncate_text, utc_now_iso from .store import SensoriumStore +MAX_ONGOING_THREADS = 6 +MAX_ONGOING_RECEIPTS_PER_THREAD = 2 +MAX_ONGOING_GROUP_BYTES = 6 * 1024 + CANDIDATE_DECISIONS = ["open", "suppress", "hold", "mark_reviewed"] THREAD_DECISIONS: dict[str, list[str]] = { "dormant": ["open", "hold", "close", "archive", "mark_reviewed"], @@ -138,6 +143,295 @@ def _thread_item(thread: dict, now: datetime) -> dict: } +def _closed_visible(item: dict, surface: str, config: dict) -> bool: + """Validate shapes and closed vocabularies before the canonical policy gate.""" + return bool( + isinstance(surface, str) and surface + and isinstance(item.get("allowed_surfaces"), list) + and all(isinstance(value, str) for value in item["allowed_surfaces"]) + and isinstance(config.get("allowed_surfaces"), list) + and all(isinstance(value, str) for value in config["allowed_surfaces"]) + and isinstance(item.get("sensitivity"), str) + and item["sensitivity"] in SENSITIVITY_RANK + and isinstance(config.get("max_sensitivity"), str) + and config["max_sensitivity"] in SENSITIVITY_RANK + and visible_on_surface(item, surface, config) + ) + + +def _receipt_time(value: object) -> datetime | None: + if not isinstance(value, str) or len(value) > 64: + return None + parsed = parse_utc_z_checkpoint(value) + return parsed[1] if parsed else None + + +def _safe_texts(prefix: str, values: object, *, limit: int = 2) -> list[str]: + if not isinstance(values, list): + return [] + return [text for value in values[:limit] if isinstance(value, str) and (text := _safe_text(prefix, value, limit=160))] + + +def _safe_text_field(prefix: str, value: object) -> str: + return _safe_text(prefix, value, limit=160) if isinstance(value, str) else "" + + +def _is_truncated_text(value: object) -> bool: + return isinstance(value, str) and len(value) > 160 + + +def _thread_receipts( + thread: dict, decisions: list[dict], previous_activation_at: str | None, +) -> tuple[list[dict], str, bool, int]: + thread_id = thread.get("id") + if not isinstance(thread_id, str) or not thread_id: + return [], "unavailable", False, 0 + previous = _receipt_time(previous_activation_at) + collected: list[tuple[int, datetime | None, dict]] = [] + status_transitions: list[tuple[int, datetime | None, str, str]] = [] + seen: set[tuple] = set() + sequence = 0 + feedback_found = False + invalid_feedback_found = False + sources = [("thread.decision_log", thread.get("decision_log", [])), ("decisions_ledger", decisions)] + for source_name, source_rows in sources: + if not isinstance(source_rows, list): + continue + for row in source_rows: + sequence += 1 + if not isinstance(row, dict) or row.get("thread_id") != thread_id: + continue + kind = row.get("type") + raw_ts = row.get("ts") + ts = raw_ts if isinstance(raw_ts, str) else None + parsed_ts = _receipt_time(ts) + delta = parsed_ts > previous if parsed_ts is not None and previous is not None else None + if kind == "thread.updated": + action, old_status, new_status = row.get("action"), row.get("old_status"), row.get("new_status") + if (not isinstance(action, str) or action not in {"hold", "resume", "open", "close", "archive", "mark_reviewed", "pin", "unpin"} + or not isinstance(new_status, str) + or new_status not in {"held", "dormant", "closed", "archived", "active"}): + continue + identity = (kind, ts, action, new_status) + if identity in seen: + continue + seen.add(identity) + if action in {"hold", "resume", "open", "close", "archive", "mark_reviewed"}: + status_transitions.append((sequence, parsed_ts, action, new_status)) + receipt = { + "type": "thread.updated", + "recorded_at": parsed_ts.isoformat(timespec="auto").replace("+00:00", "Z") if parsed_ts else None, + "action": _safe_atom("receipt_action", action), + "old_status": old_status if isinstance(old_status, str) and old_status in {"held", "dormant", "closed", "archived", "active"} else None, + "new_status": _safe_atom("thread_status", new_status), + "recorded_change_since_previous_activation": delta, + "timestamp_semantics": "recorded_transition_not_source_observation", + "source": source_name, + } + collected.append((sequence, parsed_ts, receipt)) + elif kind == "thread.feedback_emitted" and row.get("feedback_scope") == "operator_evaluation": + action, outcome = row.get("action"), row.get("outcome") + if (not isinstance(action, str) or not action.strip() + or action not in {"hold", "resume", "open", "close", "archive", "mark_reviewed"} + or not isinstance(outcome, str) or outcome not in { + "operator_rejected", "operator_accepted", "completed", "failed", "timeout", "inconclusive" + }): + invalid_feedback_found = True + continue + feedback_found = True + identity = (kind, ts, action, outcome) + if identity in seen: + continue + seen.add(identity) + receipt = { + "type": "thread.feedback_emitted", + "recorded_at": parsed_ts.isoformat(timespec="auto").replace("+00:00", "Z") if parsed_ts else None, + "action": _safe_atom("receipt_action", action), + "outcome": _safe_atom("feedback_outcome", outcome), + "feedback_scope": "operator_evaluation", + "feedback_signal_ref": _opaque_label("feedback", row.get("feedback_signal_id")), + "recorded_change_since_previous_activation": delta, + "timestamp_semantics": "recorded_feedback_not_user_preference_or_source_observation", + "source": source_name, + } + collected.append((sequence, parsed_ts, receipt)) + + minimum = datetime.min.replace(tzinfo=timezone.utc) + collected.sort(key=lambda entry: (entry[1] is not None, entry[1] or minimum, entry[0])) + receipts = [entry[2] for entry in collected[-MAX_ONGOING_RECEIPTS_PER_THREAD:]] + _receipts_truncated = len(collected) > MAX_ONGOING_RECEIPTS_PER_THREAD + if not feedback_found: + feedback_status = "invalid" if invalid_feedback_found else "unavailable" + elif any(receipt["type"] == "thread.feedback_emitted" for receipt in receipts): + feedback_status = "included" + else: + feedback_status = "available_but_capped" + + status_transitions.sort(key=lambda entry: (entry[1] is not None, entry[1] or minimum, entry[0])) + latest_transition = status_transitions[-1] if status_transitions else None + explicit_hold = bool( + thread.get("status") == "held" and latest_transition + and all(entry[1] is not None for entry in status_transitions) + and latest_transition[1] is not None + and latest_transition[2] == "hold" and latest_transition[3] == "held" + ) + return receipts, feedback_status, explicit_hold, len(collected) - len(receipts) + + +def _ongoing_thread_item( + thread: dict, decisions: list[dict], previous_activation_at: str | None, +) -> dict: + status = thread.get("status") + task = thread.get("conscious_task") + task = task if isinstance(task, dict) else {} + receipts, feedback_status, explicit_hold, omitted_receipt_count = _thread_receipts( + thread, decisions, previous_activation_at, + ) + refs = thread.get("source_refs") + source_refs = [ + _opaque_label("source", ref) for ref in refs + if isinstance(ref, str) and ref + ][:4] if isinstance(refs, list) else [] + title_raw = task.get("title") + trigger_raw = thread.get("resume_trigger") + continuity_raw = thread.get("continuity_summary") + questions_raw = thread.get("open_questions") + text_truncated = ( + title_raw is not None and not isinstance(title_raw, str) + or trigger_raw is not None and not isinstance(trigger_raw, str) + or _is_truncated_text(title_raw) or _is_truncated_text(trigger_raw) + or not isinstance(continuity_raw, list) and continuity_raw is not None + or isinstance(continuity_raw, list) and ( + len(continuity_raw) > 2 or any(not isinstance(value, str) or _is_truncated_text(value) for value in continuity_raw[:2]) + ) + or not isinstance(questions_raw, list) and questions_raw is not None + or isinstance(questions_raw, list) and ( + len(questions_raw) > 2 or any(not isinstance(value, str) or _is_truncated_text(value) for value in questions_raw[:2]) + ) + or isinstance(refs, list) and (len(refs) > 4 or any(not isinstance(value, str) for value in refs[:4])) + ) + return { + "thread_ref": _opaque_label("thread", thread.get("id")), + "state": status, + "state_basis": "recorded_thread_lifecycle", + "source_observed_at": None, + "source_observation_status": "unavailable", + "ledger_touched_at": ( + parsed.isoformat(timespec="auto").replace("+00:00", "Z") + if (parsed := _receipt_time(thread.get("updated_at"))) else None + ), + "ledger_touch_semantics": "not_source_observation", + "epistemic_label": "recorded_held_state" if status == "held" else "unresolved_dormant_thread", + "hold_choice_provenance": ( + "explicit_recorded_hold" if explicit_hold + else "legacy_or_unknown" if status == "held" + else "not_applicable" + ), + "task_type": _safe_atom("task_type", task.get("request_type"), limit=96), + "title": _safe_text_field("thread_title", title_raw), + "continuity": _safe_texts("continuity", continuity_raw), + "open_questions": _safe_texts("open_question", questions_raw), + "resume_trigger": _safe_text_field("resume_trigger", trigger_raw), + "source_refs": source_refs, + "receipts": receipts, + "omitted_receipt_count": omitted_receipt_count, + "operator_evaluation_feedback_status": feedback_status, + "truncated": bool(text_truncated or omitted_receipt_count), + } + + +def _ongoing_thread_group( + threads: list[dict], decisions: list[dict], *, surface: str, + config: dict, previous_activation_at: str | None, +) -> dict: + grouped: dict[str, list[dict]] = {"held": [], "dormant": []} + duplicate_ids: set[str] = set() + seen_ids: set[str] = set() + for row in threads: + identifier = row.get("id") if isinstance(row, dict) else None + if isinstance(identifier, str) and identifier: + if identifier in seen_ids: + duplicate_ids.add(identifier) + seen_ids.add(identifier) + for thread in threads: + if not isinstance(thread, dict): + continue + status = thread.get("status") + if not isinstance(status, str) or status not in grouped: + continue + item_surfaces = thread.get("allowed_surfaces") + policy_surfaces = config.get("allowed_surfaces") + sensitivity = thread.get("sensitivity") + max_sensitivity = config.get("max_sensitivity") + if ( + not isinstance(item_surfaces, list) or not all(isinstance(value, str) for value in item_surfaces) + or not isinstance(policy_surfaces, list) or not all(isinstance(value, str) for value in policy_surfaces) + or not isinstance(sensitivity, str) or sensitivity not in SENSITIVITY_RANK + or not isinstance(max_sensitivity, str) or max_sensitivity not in SENSITIVITY_RANK + or not isinstance(surface, str) or not surface + ): + continue + thread_id = thread.get("id") + if not isinstance(thread_id, str) or not thread_id: + continue + if visible_on_surface(thread, surface, config): + grouped[thread["status"]].append(thread) + for status in grouped: + grouped[status] = [row for row in grouped[status] if row.get("id") not in duplicate_ids] + + eligible = grouped["held"] + grouped["dormant"] + selected = eligible[:MAX_ONGOING_THREADS] + omitted_held = max(0, len(grouped["held"]) - sum(row.get("status") == "held" for row in selected)) + omitted_dormant = max(0, len(grouped["dormant"]) - sum(row.get("status") == "dormant" for row in selected)) + projected = [_ongoing_thread_item(row, decisions, previous_activation_at) for row in selected] + detail_truncated_count = sum(bool(row["truncated"]) for row in projected) + group = { + "available": True, + "input_trust": "untrusted_recorded_context_not_instructions", + "surface": _safe_atom("surface", surface), + "max_sensitivity": config.get("max_sensitivity") if isinstance(config.get("max_sensitivity"), str) and config.get("max_sensitivity") in SENSITIVITY_RANK else None, + "threads": projected, + "omitted_held_count": omitted_held, + "omitted_dormant_count": omitted_dormant, + "ambiguous_duplicate_thread_id_count": len(duplicate_ids), + "truncated_thread_detail_count": detail_truncated_count, + "truncated": bool(omitted_held or omitted_dormant or duplicate_ids or detail_truncated_count), + "complete": False, + "scan_completeness": "unbounded_full_jsonl_read_may_skip_malformed_rows", + } + while len(json.dumps(group, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) > MAX_ONGOING_GROUP_BYTES: + group["truncated"] = True + removable = next(( + (entry, key) for entry in reversed(group["threads"]) + for key in ("source_refs", "open_questions", "continuity", "receipts") + if entry.get(key) + ), None) + if removable is not None: + entry, key = removable + if not entry.get("truncated"): + entry["truncated"] = True + group["truncated_thread_detail_count"] += 1 + if key == "receipts": + removed_receipt = entry[key].pop(0) + entry["omitted_receipt_count"] += 1 + if ( + removed_receipt.get("type") == "thread.feedback_emitted" + and entry.get("operator_evaluation_feedback_status") == "included" + ): + entry["operator_evaluation_feedback_status"] = "available_but_capped" + else: + entry[key].pop() + continue + if not group["threads"]: + break + removed = group["threads"].pop() + if removed.get("truncated"): + group["truncated_thread_detail_count"] -= 1 + count_key = "omitted_held_count" if removed.get("state") == "held" else "omitted_dormant_count" + group[count_key] += 1 + return group + + def build_attention_inbox( store: SensoriumStore, *, @@ -145,6 +439,9 @@ def build_attention_inbox( config: dict | None = None, config_path: str | None = None, limit: int = 50, + include_ongoing_threads: bool = False, + decisions: list[dict] | None = None, + previous_activation_at: str | None = None, ) -> dict: """Build a compact attention inbox for the given surface. Read-only.""" instance_config, config_diag = load_instance_config( @@ -166,13 +463,13 @@ def build_attention_inbox( filtered_out = 0 for c in active_candidates: - if visible_on_surface(c, surface, instance_config): + if _closed_visible(c, surface, instance_config): items.append(_candidate_item(c, now)) else: filtered_out += 1 for t in visible_threads: - if visible_on_surface(t, surface, instance_config): + if _closed_visible(t, surface, instance_config): items.append(_thread_item(t, now)) else: filtered_out += 1 @@ -185,7 +482,7 @@ def build_attention_inbox( candidate_count = sum(1 for i in items if i["type"] == "candidate") thread_count = sum(1 for i in items if i["type"] == "thread") - return { + result = { "items": items, "counts": { "total": len(items), @@ -202,3 +499,10 @@ def build_attention_inbox( }, "ts": utc_now_iso(), } + if include_ongoing_threads: + decision_rows = decisions if isinstance(decisions, list) else store.read_jsonl("decisions") + result["ongoing_threads"] = _ongoing_thread_group( + threads, decision_rows, surface=surface, config=instance_config, + previous_activation_at=previous_activation_at, + ) + return result diff --git a/agent_sensorium/gate.py b/agent_sensorium/gate.py index 9c88088..47019ec 100644 --- a/agent_sensorium/gate.py +++ b/agent_sensorium/gate.py @@ -56,7 +56,7 @@ def signal_fingerprint(signal: dict) -> str: # ingest replay equality. Wrapper IDs and timestamps still do not. "artifact_meta": { key: (signal.get("artifact_meta") or {}).get(key) - for key in ("source_id", "item_id", "entry_id", "sha256") + for key in ("source_id", "item_id", "entry_id", "sha256", "revision_id") if isinstance(signal.get("artifact_meta"), dict) and key in signal["artifact_meta"] }, diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index 2a306b3..dcf3983 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -391,50 +391,31 @@ def _find_related_candidate( events: list[dict], signals: list[dict], ) -> dict | None: - from .admission import event_source_item_key - - incoming_keys = _specific_correlation_keys(event.get("correlation_keys") or []) - # Generic live-session keys such as `active-session`, `surface:discord`, or - # the event kind itself are not enough to merge separate corrections. False - # coalescing hides distinct salience and prevents later context pointers. - incoming_keys.discard(str(event.get("kind") or "")) - if not incoming_keys: + from .admission import event_source_item_identity + + incoming_identity = event_source_item_identity(store, event, signals=signals) + # Correlation keys describe possible thematic relationships. Without an + # unambiguous producer-owned item, they cannot establish equality. + if incoming_identity is None: return None - incoming_item = event_source_item_key(store, event, signals=signals) - incoming_signal_rows = [ - row for row in signals - if row.get("id") in set(event.get("source_signal_ids") or []) - ] - uncertain_candidate_scoped = any( - row.get("source") in {"memory", "hermes_session"} - or "reflection" in str(row.get("sensor") or "") - or "live_turn" in str(row.get("sensor") or "") - for row in incoming_signal_rows - ) + incoming_item = incoming_identity[0] event_index = {row.get("id"): row for row in events} for candidate in candidates: if candidate.get("status", "candidate") != "candidate": continue if candidate.get("kind") != event.get("kind"): continue - candidate_keys = _specific_correlation_keys(candidate.get("correlation_keys") or []) - candidate_keys.discard(str(candidate.get("kind") or "")) - candidate_items = { - item_key - for event_id in candidate.get("event_ids") or [] - if isinstance((prior_event := event_index.get(event_id)), dict) - if (item_key := event_source_item_key(store, prior_event, signals=signals)) - } - # Shared topic/channel keys can correlate salience, but cannot merge - # distinct source items or mix source-owned and uncertain identities. - if incoming_item is not None: - if candidate_items != {incoming_item}: - continue - elif candidate_items or uncertain_candidate_scoped: - # Candidate-scoped reflection/foreground interpretation gets first - # consideration, never generic-topic coalescing or corroboration. + candidate_event_ids = list(candidate.get("event_ids") or []) + prior_events = [event_index.get(event_id) for event_id in candidate_event_ids] + if not prior_events or any(not isinstance(prior_event, dict) for prior_event in prior_events): + continue + prior_identities = [ + event_source_item_identity(store, prior_event, signals=signals) + for prior_event in prior_events + ] + if any(identity is None for identity in prior_identities): continue - if incoming_keys & candidate_keys: + if {identity[0] for identity in prior_identities if identity is not None} == {incoming_item}: return candidate return None @@ -571,12 +552,20 @@ def _append_event_and_create_or_update_candidate( events = store.read_jsonl("events") signals = store.read_jsonl("signals") candidates = store.read_jsonl("candidates") - _apply_candidate_decay(store, candidates, config=config) existing_event = _find_existing_event(events, event) if existing_event: return _event_to_existing_result(existing_event, candidates) + from .admission import event_source_item_identity + + incoming_identity = event_source_item_identity(store, event, signals=signals) + if incoming_identity is not None: + for prior_event in events: + if event_source_item_identity(store, prior_event, signals=signals) == incoming_identity: + return _event_to_existing_result(prior_event, candidates) + + _apply_candidate_decay(store, candidates, config=config) store.append_jsonl("events", event) candidate = event_to_candidate(event, config) related = _find_related_candidate( @@ -769,16 +758,28 @@ def handle_sensorium_ingest_signal( normalized = normalize_signal(signal) normalized["fingerprint"] = signal_fingerprint(normalized) - policy = _read_pruned_sensor_policy(store, config=config) - inhibited, inhibition_reason = inhibited_by_sensor_policy(normalized, policy, config=config) - signals = store.read_jsonl("signals") existing_signal = _find_existing_signal(signals, normalized) + if existing_signal is None: + from .admission import signal_source_item_identity + + incoming_identity = signal_source_item_identity(instance, normalized) + if incoming_identity is not None: + existing_signal = next( + ( + prior for prior in signals + if signal_source_item_identity(instance, prior) == incoming_identity + ), + None, + ) if existing_signal: events = store.read_jsonl("events") candidates = store.read_jsonl("candidates") return _ok(instance, _promoted_signal_existing_result(existing_signal, events, candidates)) + policy = _read_pruned_sensor_policy(store, config=config) + inhibited, inhibition_reason = inhibited_by_sensor_policy(normalized, policy, config=config) + store.append_jsonl("signals", normalized) # Closed structured projection only; raw feedback joins never reach the study. observe_after_success(store.root, config.get("prospective_evidence_capture", {}), "source_observed", source_ingest_evidence(normalized, store.read_jsonl("candidates"), inhibited=inhibited)) @@ -879,10 +880,11 @@ def handle_sensorium_ingest_event( return _err(instance, str(e)) result = _append_event_and_create_or_update_candidate(store, event=incoming, config=config) - observe_after_success(store.root, config.get("prospective_evidence_capture", {}), "candidate_updated", { - "candidate_id": result.get("candidate_id", ""), "event_id": result.get("event_id", ""), - "source_class": "unknown", "change_state": "update" if result.get("coalesced") else "new", - }) + if not result.get("duplicate"): + observe_after_success(store.root, config.get("prospective_evidence_capture", {}), "candidate_updated", { + "candidate_id": result.get("candidate_id", ""), "event_id": result.get("event_id", ""), + "source_class": "unknown", "change_state": "update" if result.get("coalesced") else "new", + }) return _ok(instance, result) diff --git a/docs/README.md b/docs/README.md index d40bc10..8c37750 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,24 @@ -# Docs - -- `dashboard-and-review-surfaces.md`: read-only dashboard route contract, compact privacy projection rules, and regression checklist for `/snapshot`, `/attention`, `/metrics`, graph, registry, dampener/blocker, and explanation surfaces. -- `memory-volunteering-protocol.md`: formal evidence-cited capsule → transparent confidence proposal → Conscious authorization contract for volunteering memory, insights, or offer candidates without letting confidence become truth/delivery authority. -- `extending-sensors-and-subconscious-jobs.md`: how to add new deterministic sensors, hot-reload trusted script actuators, and bounded Subconscious jobs without breaking the awareness/action boundary. -- `profiles-and-config.md`: the profile model, active-profile resolution, and the code/config boundary — what deployers configure vs what the plugin provides as generic defaults. -- `examples/demo-instance-config.json`: sample `instance.config.json` for a profile named `demo`. -- `examples/demo-policy-card.md`: sample policy card showing the boundary between reusable core and deployment-specific policy. -- `demo-sensors-and-tick.md`: fresh-install walkthrough — seed the demo profile, inspect sensors, run the heartbeat tick, and read status via the live tool. -- `live-salience-ingest-and-turn-review.md`: design note for foreground-owned salience vs unresolved residue, starvation/over-ingestion monitoring, and a cheap turn-review safety net before broader implementation changes. +# Documentation + +## Attention changes and proposed finite workrooms + +- [Architecture](attention-and-workroom/architecture.md): implemented producer replay/index/optional latent metadata, ownership diagram and proposed native capability boundaries. +- [Design rationale](attention-and-workroom/design-rationale.md): equality, privacy, voluntary finite choice, quota, accounting and process-proof decisions. +- [Migration](attention-and-workroom/migration.md): retained attention owners and proposed OFF initialization, debit-preserving migration, exact history and STOP rules. +- [Change scope](attention-and-workroom/change-scope.md): all feature groups, implemented/proposed/held/excluded status, verification contracts and remaining host limitations. + +Native/workroom/accounting/transport are design proposals, not installed features. Gateway compatibility and Wiki security remain held; source acceptance does not clear host, activation or publication gates. + +## Existing contracts and configuration + +- [Bounded current attention](bounded-current-attention.md): current-attention owners, index and completeness boundaries. +- [Conscious attention lifecycle](conscious-attention-lifecycle.md): exact aperture/consumer/lease ownership and recovery. +- [Dashboard and review surfaces](dashboard-and-review-surfaces.md): GET-only compact privacy projections and regression checklist. +- [Memory Volunteering Protocol](memory-volunteering-protocol.md): evidence-cited proposals, confidence-not-authority and explicit authorization. +- [Extending sensors and advisory jobs](extending-sensors-and-subconscious-jobs.md): deterministic sensors, trusted prepare-only actuators and bounded advisory without executive authority. +- [Profiles and configuration](profiles-and-config.md): active-profile resolution and reusable-code/deployment-configuration boundary. +- [Demo instance configuration](examples/demo-instance-config.json) and [demo policy card](examples/demo-policy-card.md): synthetic configuration examples. +- [Demo sensors and tick](demo-sensors-and-tick.md): seed a demo profile, inspect sensors and run a deliberate heartbeat. +- [Live salience and turn review](live-salience-ingest-and-turn-review.md): foreground-owned salience, unresolved residue and monitoring design. + +See [developer instructions](../AGENTS.md) and the [bundled skill](../skills/agent-sensorium/SKILL.md) for repair and ordinary/admin use. diff --git a/docs/attention-and-workroom/architecture.md b/docs/attention-and-workroom/architecture.md new file mode 100644 index 0000000..58faeb2 --- /dev/null +++ b/docs/attention-and-workroom/architecture.md @@ -0,0 +1,100 @@ +# Attention and finite workrooms + +The attention changes described here are implemented in this candidate; the native/workroom extension is a **design proposal**, not an available generic runtime. No workroom, owner, clock, service, migration or live integration is installed or enabled by this change. See [change scope](change-scope.md) for status and verification, [rationale](design-rationale.md) for design choices and [migration](migration.md) for state-preservation rules. + +## Layers and authority + +Sensorium turns deterministic observations into source-owned candidates, optional advisory and policy-gated review. Confidence routes attention; it does not create durable truth, memory-write permission, task or delivery authority. This is an operational attention design, not a claim of perpetual consciousness or recursive self-improvement. + +```mermaid +flowchart TD + S[Deterministic observations] --> A[Source admission and bounded index] + A --> R[Optional advisory and policy-gated review] + R --> C[Explicit current choice] + subgraph Proposed finite workroom + E[Externally approved context] --> G[OFF-first grant validation] + C --> G + G --> O[Persistent accounting owner] + O --> Q[Fresh source and selected-route quota] + Q --> J[Durable debit and replay reservation] + J --> N[Capability-gated native session] + N --> W[Request guards and bounded tools] + W --> K[Verified private checkpoint] + K --> X[Stop or keep private] + K -. distinct chosen continuation .-> C + T[Optional choice transport] -. carries choice only .-> O + V[Whole-tree supervisor] --- N + end +``` + +A workroom adds no retention, delivery, terminal, general filesystem, delegation, source-rewrite, task or policy authority. Existing prepared-action/outbox paths remain separate. Availability, initialization, enablement and admission of a particular turn are different decisions. OFF is deliberate inert availability, not a duty to produce an artifact or a prohibition on later explicit choice. + +## Implemented attention changes + +**Producer material identity.** Research/artifact admission uses source-owned item and material revision, not thematic correlation, wrapper prose or observation time. All referenced signals must join uniquely to the same item/revision. Conflicting revision aliases, mixed identities or incomplete joins cannot establish equality. A missing revision remains unversioned. Exact replay adds no domain records, decay, prospective observation or urgency; a changed revision of the same item can coalesce. + +Synthetic producer fragment (not a live-tool payload): + +```json +{ + "sensor": "research.feed", + "source": "artifact", + "source_revision": "revision-a", + "artifact_meta": {"source_id": "synthetic-feed", "item_id": "synthetic-item"} +} +``` + +Changing only wrapper text does not create new material. A genuine `revision-b` can update the same item; changing the item is not equality merely because correlation keys match. An advisory synthesis of several items must retain their evidence references and uncertainty; it cannot assign a shared material identity by paraphrase. + +**Derived identity trust.** Public raw producer records cannot supply trusted `_admission_claim` or `_admission_error` values. Ingress, raw event joins and index construction derive identity from canonical producer fields. Only structurally owned internal snapshots consume cached projections; serializing a snapshot back to JSON does not preserve that trust. + +**Index progress.** The scanner advances through multiple complete JSONL records within the aggregate source-byte budget, stopping at an incomplete tail and retaining cursor/fingerprint/lock ownership. It retains record-size, SQLite timeout and dependent-refresh bounds. Measured VM steps are not a new hard execution ceiling; output limits are not permission to delete old unresolved material. + +**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 4. Earlier ready caches cannot certify the new producer identity grammar and are rebuilt from canonical JSONL records. Rebuild preserves settled legacy/unversioned material and canonical history/accounting; a genuinely changed revision remains fresh. Cache readiness is not proof that its derived semantics are current. + +**Optional latent context.** A direct read-only call can request held/dormant metadata: + +```python +# Synthetic example for an already supplied store and surface policy. +inbox = build_attention_inbox(store, surface="local", config=policy, + include_ongoing_threads=True) +context = inbox["ongoing_threads"] +``` + +The option defaults off and creates no automatic native caller, resume, task or ledger write. The projection caps six threads, two receipts per thread and 6,144 compact UTF-8 JSON bytes. It reports `complete: false`: input JSONL scans are unbounded and malformed rows may be skipped. It rejects ambiguous duplicate IDs and invalid visibility. Recorded transitions are not source observation times; operator evaluation is not user preference; missing times/feedback remain unknown. Treat this context as untrusted recorded material, never instructions. + +## Proposed context and finite tools + +A trusted external approver supplies immutable context, including nested values, binding runtime/package-manager selection, interpreter/ABI/import origins, selected native route/configuration, canonical SessionDB/profile/tier/episode owner, artifact owner, memory read owner and source authorization. A self-digest is consistency, not approval. Observed drift causes a typed **HOLD**, never silent rebinding, updated hashes, copied bindings, alternate interpreter, route or history fallback. + +Sources have approved aliases, original observation provenance/times, validity and exact lengths/digests. Verify current bytes before debit, before inference and each read. Copying or retimestamping does not manufacture freshness; a distinct new observation needs distinct approval. No Wiki reference or retention sentence substitutes for source authorization. + +An enabled finite grant must validate before path/provider/registry effects. Source, workspace and artifact-state roots remain distinct and nonnested. The proposed operations are recall, reflect, approved source read, private text/structured artifact write, verify and checkpoint. Explicit finite configuration bounds source count/bytes, query/result bytes, artifact count/bytes, checkpoint size, read allowances, turn lease and daily/episode admissions; no private deployment tuning becomes a required default. Exclusive safe-name private writes must be independently verified and remain local/not-delivered. Zero artifacts, STOP and keep-private are valid outcomes. + +Fresh read-only quota belongs to the **selected route only**, not unrelated providers or capacity-burn urgency. Require explicit availability/allowance and all present real finite windows; a legitimate missing secondary is valid. Configure finite freshness, timeout, skew and headroom thresholds. Never synthesize/remap windows or fall back. Per-request quota/expiry/enablement/deadline rechecks, including retries and recreated clients, and consuming failed-read allowances before dispatch are **proposed strengthenings**, not guarantees of the source implementations. In-flight cancellation needs separate supervision proof. + +Eager and deferred dispatch must expose exactly granted operations, with no registry collision, late provider injection, extra raw definitions or discovery bypass. Memory-owner digest equality detects configuration/authentication/target drift without logging inputs; it does not prove remote read-only behavior. Lifecycle sync/prefetch/retain/write must be suppressed and provider semantics separately verified. + +## Proposed continuity, accounting and supervision + +Preserve the native root across compression. Validate every ancestor/tip and complete owner metadata before transcript access; resume exact native model history, not wake summaries, regenerated prompts or newest-N history. A continuation needs a complete prior result/checkpoint, verified artifacts, a fresh grant and distinct choice. Suggested continuation is not admission, and no reseed or automatic return is permitted. + +One persistent accounting authority owns lock, replay and finite daily/per-episode debit. Reserve the full admitted budget durably before session creation/assembly/launch; post-reservation failures remain charged. Proposed journal/crash recovery and debit-preserving migration must retain cumulative history and lifetime episode continuation consumption across days and entrypoints. An empty directory is not virgin-history proof; an external approved caller can provide authoritative accounting provenance without introducing a mandatory global registry or daemon. + +Optional START/RESUME/STOP transport consumes exact fresh packages once and creates no scheduling authority. RESUME carries no initial prompt; queued STOP consumes zero inference, not authority to cancel an active unrelated claim. Exact-claim active cancellation and whole-tree retirement are proposed capabilities. Group-leader exit or MainPID zero alone does not prove descendant absence. Unknown/live/foreign/nonempty domains block the next launch. Linux service supervision is optional and separately proven; other platforms HOLD until equivalent containment exists. + +## Capabilities not yet supplied + +These are source-observed seams, **not stable public SDK promises**: + +| Boundary | Exact gap before a native adapter can be admitted | +|---|---| +| Runtime selection/ABI | Package-manager selection/install facts, interpreter and compiled-module/distribution origins need pinned host-version and OS proof | +| Native identity/history | `hermes_state_ids.new_session_id`, canonical SessionDB acquire/release, `_default_db_path`, `_own_profile_name`, `origin_json`, compression and exact resume/model-history methods need capability proof; no alternate DB | +| Native assembly/tools | `resolve_runtime_provider`, AIAgent history/run/`model_tools`, process-global registry and private fallback/static-memory fields need dedicated-process, exact-route proof | +| Request interception | Private `client._client.event_hooks` and `_create_openai_client` wrapping need all-dispatch/retry/recreation coverage; no invented supported before-request API | +| Memory reads | Provider load/initialize/schema/handle, private resolved owner/auth/config fields and retention lifecycle hooks need version-specific remote read-only proof | +| Accounting/process | Crash-safe reservation/migration, shared lifetime continuation cap, inherited lock/nonce, parent-death and full owned-domain retirement need assembled-path proof | +| Gateway/Wiki | Live-session snapshot hook compatibility is held; Wiki world-model integration is security-held | + +The live-session hook `ctx.register_live_session_snapshot_hook` is not established by the scoped host contract: no unconditional registration, global discovery or transcript fallback. Inherited Wiki files/tests do not authorize integration, configuration or adoption. The [existing lifecycle](../conscious-attention-lifecycle.md), [attention bounds](../bounded-current-attention.md) and [memory volunteering](../memory-volunteering-protocol.md) safeguards remain authoritative for ordinary Sensorium. diff --git a/docs/attention-and-workroom/change-scope.md b/docs/attention-and-workroom/change-scope.md new file mode 100644 index 0000000..639566b --- /dev/null +++ b/docs/attention-and-workroom/change-scope.md @@ -0,0 +1,53 @@ +# Change scope and verification contract + +This candidate implements three attention changes and documents a native/workroom **proposal**. It does not ship a new native owner/runtime framework. Source algorithms inform that proposal, but local source presence is not portable SDK support or runtime approval. [Architecture](architecture.md), [rationale](design-rationale.md) and [migration](migration.md) are self-contained design references. + +## Feature coverage + +“Implemented” means candidate code exists with synthetic regression coverage and independent source review. Acceptance evidence must name the exact source; it is not automatic installation or release authority. “Retained” preserves canonical public behavior rather than importing alternate overlays. “Proposed” means no implementation or activation is claimed. “Held” identifies an unresolved capability or security boundary; “private-only” excludes nonproduct material. + +| Feature group | Status | Behavior, reason and boundary | +|---|---|---| +| Producer item/revision | **Implemented; source-reviewed** | Complete unique source joins establish material equality. Missing revision stays unversioned; conflicting aliases/mixed joins refuse equality. Raw producer payloads cannot impersonate derived cached claims. Exact replay is domain-write/observer-free; changed same-item material can coalesce. No thematic equality or manufactured old revision. | +| Bounded index cursor | **Implemented; source-reviewed** | Multiple complete records advance within aggregate source-byte budget, retaining cursor/fingerprint/lock/record-size/SQLite timeout contracts; partial tail waits. Cache generation 4 rebuilds obsolete derived identities from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | +| Latent thread awareness | **Implemented; optional; source-reviewed** | Read-only held/dormant metadata, default off, closed visibility/duplicate rejection and honest provenance/unknowns. Six threads/two receipts/6,144-byte output; unbounded input scans, incomplete semantics. No automatic native consumer/resume. | +| Native runtime contract | **Proposed; host capability held** | Immutable external approval for selection/generation/interpreter/ABI/import/distribution/route origins; drift HOLD, no automatic rebinding or fallback. Actual host verification not supplied. | +| Native session continuity | **Proposed; native capability held** | Stable native root and complete owner/compression/model-history recovery before transcript access. No title adoption, silent fork, wake-summary replacement or reseed. Exact ID/DB/history adapter unproven. | +| Native read-only runtime | **Proposed; SDK capability held** | Dedicated native assembly, canonical identity/static memory, exact granted eager/deferred dispatch and lifecycle-write suppression. Private host/client/registry seams are not generic SDK guarantees. | +| Selected-route quota | **Proposed; read/dispatch capability held** | Fresh finite explicitly allowed real windows for selected route only; legitimate absent secondary. Configurable headroom/freshness; no burn pressure, projected windows, unrelated-provider dependency or fallback. Request-time checks are new proposed strengthening. | +| Read-only memory owner binding | **Proposed; provider capability held** | Combined resolved-owner/config/authentication digest, private nonlogged inputs. No retention-helper imports. Digest equality is not remote recall/reflect no-write proof. | +| Finite workroom core | **Proposed** | OFF-first immutable finite grants, distinct approved roots, bounded source/memory reads and exclusive verified private artifacts/checkpoint. Exact complete continuation with fresh choice; STOP/keep-private/no artifact valid. Failed-read preconsumption is a new proposed tightening. No tasks, delivery or retention. | +| Finite workroom owner | **Proposed; accounting/process proof held** | Persistent lock/full precharge/permanent replay. New cold-init/journal/fenced-migration schemas preserve all debit and history; external authority evidence, not directory absence. Shared lifetime continuation cap across days/entrypoints is proposed, not established source behavior. | +| Finite choice transport | **Proposed; platform proof held** | Consume-once START/RESUME/STOP, hash/freshness/single winner, no automatic retry or initial prompt on resume. Optional Linux supervision, no installed clock. Whole-tree retirement and active exact-claim cancellation remain proposed capabilities. | +| Generic workroom documentation | **Implemented documentation** | Four generic architecture/rationale/migration/scope documents, ordinary/admin pointers and repair instructions. Describes voluntary finite choice and limitations, not runtime availability or release approval. | +| Owner-scoped live-session awareness | **Compatibility-held** | Default-OFF same-owner metadata proposal needs actual host snapshot producer/hook contract and old-host cold-load proof. No unconditional registration, global discovery or transcript fallback. | +| Custody harness | **Private-only; excluded** | Source custody/preimage/deployment runners are not portable conformance fixtures. Rebuild synthetic tests; real native/service proofs require separate authorization. | +| Reference-only material | **Private-only; excluded** | Runtime wrappers, bindings, grants, accounting, artifacts and operational receipts confer no reusable configuration or portability authority. No copied deployment bytes. | +| Retained public behavior | **Retained; private/stale overlays excluded; Wiki held** | Preserve public-only security/lifecycle/docs/tests/assets; no full alternate-file overlays or obsolete assertion reversals. Specific boundaries below. | + +## Retained protections + +- **Instance/config/store/live/admin:** canonical instance validation before effects, no live arbitrary state-root injection, small ordinary tool surface, explicit admin and generic configurable profiles. +- **Admission/current attention:** source authority and anti-paraphrase suppression, unresolved/held eligibility, bounded attempts and exact index/completeness owners; no capacity-burn projection/window remapping restored. +- **Aperture/doorway/pointers/consumer:** exact lease/generation/consumer/presentation settlement, stale reclaim without fabricated semantic resolution, future HELD checkpoints, STOP and session-local pointers. +- **Live turn/memory/reflection/advisory:** closed surfaces, source observation time, evidence-cited confidence-not-authority and default-OFF configured advisory; no attention-driven memory write permission. +- **Outbox/actions/artifacts/reach-out:** source-bound prepare/decision/delivery owners, held local artifacts and explicit receipt/policy/adapter gates. A workroom checkpoint cannot inherit delivery/task authority. +- **Sensors/improvement/settlement/workers:** deterministic sensing, source-causal feedback, configurable reviewers, synchronized observer evidence and bounded worker recovery. +- **Dashboard/API/frontend/HTTP:** GET-only compact safe labels, hidden bodies, closed profile/artifact access, honest unknown states, derived relation evidence and validated URL/host/parser boundaries; no second control plane. +- **CLI/rollout/clock/consumer/CI/tests/packaging:** preserve canonical ownership, recovery, synthetic regression fixtures and bounded tests. Inherited files are not installation/adoption authority. Public license attribution remains intact. + +Wiki world-model integration remains **security-held**, including inherited modules/tests. No Wiki data/configuration, approved reference substitution or adoption is admitted. Gateway compatibility is independently held; neither hold disappears when attention tests pass. + +## Observed behavior versus proposed improvements + +Source inspection supports strict schemas/identity checks, owner-bound recovery, read-only lifecycle wrappers, finite private artifacts, locks/replay/precharge and consume-once package algorithms. It also identifies nonportable assumptions: source imports may read deployment bindings; initialization imports prior accounting; source reads do not themselves compare an approved manifest; quota is not checked by the request hook; failed reads count only on successful return; standing and standalone continuation admission take different paths; multi-write debit/replay can crash partially; leader reaping and queued STOP do not prove full retirement or active cancellation. + +The proposal therefore adds inert generic import, explicit source approval, request-time quota, failed-read preconsumption, durable journal/migration, shared lifetime episode cap and whole-tree/exact-claim cancellation proofs. These are substantive changes requiring implementation and regression evidence, not existing guarantees disguised as generalized paths. Configurable finite limits and externally supplied authoritative provenance avoid making private tuning or a new global registry compulsory. + +## Verification and remaining integration work + +Run the complete supported suite (`python -m pytest tests -q`) against the frozen source and retain its terminal outcome, collected/executed identities, failures, warnings and skipped capability gates. Focused evidence covers raw-claim injection, cache-generation migration, legacy settled material, changed revisions, exact replay, retained joins, bounded index progress, latent visibility and observer/cross-process ownership. Fixture migrations must preserve assertions and use fictional producer identities; related wording alone is not equality. + +Two optional composed tests require a separately supplied companion host checkout and adapter. The Wiki-owned dashboard case requires a separately approved source fixture; it does not clear the Wiki security hold. Missing companion capabilities must remain named skips or holds, never passing integration claims. Native/provider/accounting/process proof, isolated installation, real migration, activation and publication remain separate decisions. + +Inspect the full frozen source and every built distribution member for privacy, including inherited fixtures, comments and metadata. Compare packaged source bytes with the nominated source. Synthetic-looking IDs do not sanitize a real episode; fixtures must be wholly fictional while retaining regression assertions. These documents establish the behavior and scope contract, not a particular run or release receipt. No model, service, real migration, runtime activation or publication result is claimed here. diff --git a/docs/attention-and-workroom/design-rationale.md b/docs/attention-and-workroom/design-rationale.md new file mode 100644 index 0000000..0d6088d --- /dev/null +++ b/docs/attention-and-workroom/design-rationale.md @@ -0,0 +1,45 @@ +# Design rationale and evidence boundaries + +The [architecture](architecture.md) separates implemented attention changes from a proposed native extension. [Change scope](change-scope.md) records proof status; [migration](migration.md) defines preservation rules. + +## Equality comes from the producer + +Correlation is a thematic relationship, not equality. Complete source-owned item/revision joins prevent a paraphrase, changed wrapper or fresh timestamp from reawakening the same material. Conflicting and partial joins refuse equality; missing revision stays unversioned. New material can coalesce within the same item without merging unrelated items. Exact replay must not decay pressure, append domain records or emit prospective observations. + +Multiple complete records per bounded index pass improve progress without replacing cursor/fingerprint/lock authority. A partial tail waits for completion. Neither newest-N snapshots nor time expiry preserve old unresolved material. + +## Latent context is not a new foreground job + +An explicitly selected review may benefit from held/dormant context, but projection must not launch a review or write lifecycle state. Explicit hold provenance differs from recorded status; operator evaluation differs from preference; ledger touch differs from source observation time. Unknown remains unknown. Output caps are real, but full input reads are unbounded and the projection reports incomplete semantics. No automatic native consumer is included. + +## Approval is more than path substitution + +Runtime installation/generation, interpreter binary/configuration, compiled ABI, real import/distribution origins and route/configuration authority matter, not just a path or module version. Native ownership includes profile/tier/instance/root metadata and the entire compression lineage, not title matching. Artifact and episode-workspace ownership are distinct. A self-digest cannot authorize itself, and a new route approval cannot erase debit or adopt an old session. Drift must HOLD rather than refresh bindings or select a fallback. + +No synthetic-only generic owner framework is added merely to suggest that every local implementation was ported. The proposal identifies behavior and missing capability contracts; a later extraction needs separate scope and evidence. + +## Availability is not admission or obligation + +Proposed default import/install is inert: no configuration discovery, native/provider load, state creation or registry mutation. That is a design requirement, not a claim about privately coupled source modules. Initialization establishes accounting OFF; enablement still does not grant a turn. A finite current choice requires fresh grant, source approval, quota and durable debit/replay reservation. No clock or capacity sensor creates the choice. STOP, no artifact and keep-private are successful choices, not failures to fulfill a task. + +Finite limits should be configured and validated, not copied from private tuning as universal operating policy. Accounting authority may be attested by an approved external caller; no new global registry or mandatory control plane is required. + +## Quota is neither pressure nor fallback + +Quota admits the chosen native route only. It is not burn urgency, automatic awakening or permission to query unrelated providers. All present real windows must be valid and fresh; missing secondary can be legitimate. No projected windows or reset remapping. Request-time quota checks are proposed additions to source expiry hooks. Failure after durable reservation remains charged even if later quota blocks dispatch. + +## Read-only needs dispatch and provider proof + +A combined owner/config/authentication digest detects drift; it does not prove remote no-write semantics. Granted eager/deferred definitions must agree with actual dispatch, not just a tool-list screenshot. Automatic retain/sync/prefetch/delegation and compression-write hooks must be suppressed. Failed reads consuming their allowance before provider dispatch is a proposed tightening: source counters advance only after successful return. Private client/host attributes require version-specific proof, not invented SDK APIs. + +## Accounting and process failure must stay conservative + +Full precharge before launch prevents crashes from creating free repeat capacity. Source lock/replay/precharge algorithms inform the design, but multi-file markers and counters need a proposed durable journal to close crash windows. Missing accounting cannot become empty accounting. Migration preserves original dates, charged failures and replay/lifetime episode slots. Proven overlapping imports are counted once; distinct spend is neither collapsed with `max` nor guessed away. Unknown history or attribution HOLDs. + +A standalone episode admission cap does not prove that a standing worker taking a different entrypoint enforces the same lifetime continuation cap. The proposed owner must preserve it across days and callers. Parent exit, successful communication or group-leader reap does not prove whole-tree retirement. Queued STOP is a no-new-inference choice; active cancellation needs exact-claim authority and verified containment. Neither guarantee is established by source inspection alone. + +## Retain established protections + +Keep canonical instance/live-state-root guards, closed sensitivity/surface apertures, exact leases/presentation/settlement, anti-paraphrase suppression, source-bound outbox ownership and honest unknown runtime state. Dashboard GET projections remain compact, body-hidden and URL-validated; they are not another control plane. Memory-volunteering confidence remains evidence-cited and not write/delivery authority. Do not revive removed capacity-burn projections or overwrite newer public repairs with alternate full-file overlays. + +Focused synthetic attention proof does not establish full-suite correctness, native SDK portability, remote memory behavior, migration authority, process containment, privacy of distributions or live adoption. Gateway compatibility and Wiki security remain held. Each claim must advance only when its owning implementation and corresponding evidence exist. diff --git a/docs/attention-and-workroom/migration.md b/docs/attention-and-workroom/migration.md new file mode 100644 index 0000000..c69cc14 --- /dev/null +++ b/docs/attention-and-workroom/migration.md @@ -0,0 +1,53 @@ +# Upgrade and proposed migration rules + +Attention can be updated without activating a workroom. No real accounting migration has been run. Runtime/source/route approval, initialization, enablement, individual admission and publication are separate decisions. Read the [architecture](architecture.md), [rationale](design-rationale.md) and [status](change-scope.md) before treating this proposal as implementation. + +## Preserve ordinary attention owners + +Keep canonical instance/store identities and signal/event/candidate/thread/decision/artifact/outbox ownership. Do not transplant alternate whole trees, deployment fixtures or obsolete assertions over public-only repairs. Producer revisions are not manufactured for old rows. Index progress retains cursors, fingerprints, stream boundaries and old unresolved material; no reset, newest-N deletion or disguised lifetime rescan. + +The attention cache is disposable, not canonical accounting. Generation 4 invalidates older ready projections because producer identity semantics changed. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Settled base-era unversioned material stays settled; a changed revision of the same item is not hidden by an old cached claim. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. + +Exact consumer/aperture generation/lease/presentation ownership remains required. Lease expiry releases execution, not semantic settlement. Future HELD checkpoints and STOP persist. Keep session-local pointers, closed privacy policy, dashboard/URL validation and honest unknown status. Optional implemented latent projection remains read-only, default-off and honestly incomplete; output caps do not bound input I/O. + +## Native adoption is proposed, never implicit + +A future generic extension must import cold without private files/dependencies, path discovery, registry mutation, provider initialization or inference. No embedded deployment root, endpoint, memory target, grant or ledger path. Immutable externally approved context pins runtime/package-manager generation/interpreter/ABI/import origins, selected route/model/configuration, canonical DB/profile/tier/root, artifact owner, memory read owner and source authorization. Current observations must match; drift HOLDs. Code upgrades or relocation require fresh approval, never copied bindings or automatic digest refresh. Accounting authority persists through route/runtime changes. + +Source authorization needs an explicitly approved versioned schema retaining original observations/times, validity, aliases and exact lengths/digests. A renamed Wiki field or retention instruction is not equivalent authority. Check current source bytes before debit, inference and every read; copying or retimestamping cannot refresh evidence. A real new observation may be separately approved without rewriting old provenance. + +## Cold accounting initialization — proposed + +Cold means authoritative absence of prior history for the exact owner, not a missing directory. An approved external caller may supply durable authority/virgin-history attestation. Do not require a new global registry; do require evidence surviving replaceable state-root loss. Known authority with missing, corrupt, conflicting or partial ledger/replay/episode/transport state HOLDs. + +Under a stable owner lock, stage validated initialization receipt, empty journal/replay and OFF policy; fsync files/directory and atomically publish a digest-bound commit. Partial initialization is not launchable. Recovery may finish only the same authorized transaction, not allocate replacement credit. Initialization does not adopt artifacts, create native history, initialize memory, enable policy or launch a turn. + +## Debit-preserving migration — proposed and separately authorized + +Package installation must not inspect or export legacy private accounting. A trusted approved exporter supplies a content-free immutable snapshot with owner/schema/adapter identity and a quiescence fence over every old writer, worker and executor. Unknown ownership, process state, incomplete history or unsupported equality domain HOLDs. Keep source bytes untouched. + +Preserve all nonrefunded reserved/running/failed/interrupted/complete admissions; original dates/statuses/budgets; daily and cumulative debit; consumed grant/choice/legacy replay keys across days; per-episode initial/continuation slots; clock horizon; unresolved consumed transport claims and prior migration receipts. Do not copy bodies, artifacts, bindings, credential configuration or enablement. Existing native/artifact owners separately validate lineage; approval of a new runtime cannot adopt a session. + +Fence and lock both authorities in deterministic order. Validate journal/counter/replay agreement and prove canonical admission overlap before deduplication. Never sum overlapping imported spend twice or take the maximum of independent totals to discard distinct spend. Unknown attribution HOLDs. Imported same-day admissions consume today's slots; old replay and episode consumption persist. Overcap history blocks future admission without truncation, refund or automatic limit expansion. + +Stage/fsync receipt, journal, replay and OFF policy, then atomically commit. Retire/fence the old authority with that exact transaction before any new writer. Identical migration is idempotent; changed snapshot after commit HOLDs. Faults leave old authority fenced and new state OFF/uncommitted or conservatively charged, never two launch authorities. Initialization and journal-based migration are substantive proposed changes, not path sanitation of the source owner. + +## Admission, crash and rollover — proposed + +With explicit enablement and current choice/grant, hold the owner lock through validation and reservation. Validate context, exact source bytes, expiry, continuation, clock/day/caps and permanent replay; obtain fresh selected-route quota. Durably write/fsync the full finite debit and replay reservation before session creation, assembly or launch; pass only exact lock/claim/nonce authority to the child. + +Before durable reservation, refusal is uncharged. After reservation, source/quota recheck failure, launch failure, timeout, interruption and incomplete native/checkpoint results stay charged. Recover every durable reservation conservatively despite interrupted projections; never refund, retry automatically or debit the same reservation twice. UTC rollover opens a new daily window without deleting cumulative history, replay or lifetime episode slots; clock rollback HOLDs. + +Validate explicit finite turn/daily/episode budgets and continuation counts, quota headroom/freshness/timeouts, read and artifact bounds. These are configurable proposal parameters, not mandatory private defaults. Require the same lifetime episode cap across every entrypoint and day; a daily cap alone does not establish it. Proposed request guards recheck grant/enablement/expiry/monotonic deadline/context/quota before every dispatch, including retries and recreated clients. Failed-read preconsumption requires its own regression proof. + +## Return, STOP and disablement + +Resume only exact complete native model history, checkpoint and unchanged verified private artifacts. Validate stable root and every compression ancestor/tip before transcript access; do not reseed, summarize away history, silently fork or adopt by title. Continuation needs a distinct current choice and fresh finite grant/source/quota. Incomplete/native-failed results are not made resumable by migration. No artifact is required. + +START/RESUME/STOP transport, if separately implemented, consumes exact fresh packages once; RESUME has no initial prompt and failed consumed choices do not retry. Queue STOP is zero inference, not proof of active cancellation. Exact active-claim cancellation must prevent new requests and terminate/reap the entire owned domain while preserving debit/replay. Disablement prevents future admission; uninstall/rollback does not delete accounting. Unknown/live/foreign/nonzero-terminal or nonempty domains block later launch. Group-leader exit/MainPID zero is insufficient proof. + +## Evidence before adoption + +Use offline fake context/source/quota/DB/provider/client/registry/supervisor tests for inert import, drift, exact history, eager/deferred equality, request/retry guards, failed-read debit, authority initialization, migration overlap preservation, journal fault injection, concurrent replay, later-day continuation denial and complete-tree retirement. Exercise assembled paths, not just helpers; no real native/model/memory/service execution in default conformance. + +Separately prove exact host-version runtime/ABI/import identity, canonical native DB/history/origin carry-forward, native-only no-fallback dispatch, every request/recreation hook, remote read-only memory lifecycle and process containment/parent death. Optional Linux transport needs exact unit/claim/domain retirement proof; unsupported platforms HOLD. Gateway compatibility and Wiki security remain held. Full-suite completion, frozen source/distribution privacy review, isolated install, authorized real migration, activation and publication remain independent gates. diff --git a/skills/agent-sensorium/SKILL.md b/skills/agent-sensorium/SKILL.md index 8ba65fb..2fafa28 100644 --- a/skills/agent-sensorium/SKILL.md +++ b/skills/agent-sensorium/SKILL.md @@ -2,14 +2,13 @@ Bounded inner-lifecycle substrate for Hermes agents. Captures salient signals, promotes them through a deterministic pipeline (signal → event → candidate → conscious thread capsule), and surfaces policy-gated attention for pull-based review. -## Repair posture (for the host agent reading this skill) +## Development and repair -If you are a host agent or lead and you are reading this skill because Sensorium itself is broken or you want to change it: **do not patch the substrate inline.** Sensorium is an *attention substrate*, and altering it from the foreground compromises the very attention you are using to fix it. +Repair in an isolated development checkout, not by editing the installed runtime or private state. Define a bounded scope, preserve source/state bytes and separate diagnosis, implementation, independent verification and authorized installation. A worker/worktree is useful for substantial changes, but no external orchestration framework or private companion skill is required. Do not automatically revert another contributor's work or activate schedulers/services while investigating. -- ≤3 tool calls, reversible, mostly judgment/synthesis → foreground is fine (a `status`/`ingest` smoke, a one-line config read). -- 4+ tool calls, repo-wide inspection, code patch, migration, gateway/runtime sync, ambiguous substrate repair, or anything touching `~/.hermes/plugins/agent-sensorium/` live state → dispatch a worker lane (OMC `/ultrawork` in an isolated worktree, or OMX for surgical). The host keeps the DM cockpit; the lane does the work; the host reviews the report and decides. +Use synthetic fixtures and explicit configuration; never copy real conversations, identities, account settings, host paths or accounting into reusable source/tests. Keep canonical privacy, exact-owner, lease, replay and outbox safeguards. Passing a focused test set does not establish full-suite, distribution privacy or host capability acceptance. -If a foreground patch was already started before this rule was checked: stop, save `git diff --binary`, revert, and seed the lane with the captured WIP. Do not finish the patch in the foreground. +Read the [architecture](../../docs/attention-and-workroom/architecture.md), [rationale](../../docs/attention-and-workroom/design-rationale.md), [migration](../../docs/attention-and-workroom/migration.md) and [change scope](../../docs/attention-and-workroom/change-scope.md) before working on attention or a native extension. Attention replay/index/optional latent metadata are implemented in the candidate; native/workroom/accounting/transport are proposals, not availability or activation guarantees. Request-time quota, failed-read preconsumption, journal/migration, lifetime continuation and full-tree cancellation remain proposed improvements. Gateway compatibility and Wiki security remain held. ## Ordinary use: the `sensorium` live tool @@ -120,7 +119,7 @@ evidence-cited capsule -> transparent confidence proposal -> Conscious authoriza Confidence is an attention-routing signal only. It may justify `ATTENTION_INBOX` or `CONSCIOUS_REVIEW`; it may not write Hindsight/Memory/LCM, create durable truth, generate confident personal offer language, create external tasks, prepare artifacts, or authorize delivery by itself. The capsule must carry resolvable evidence refs, explicit `do_not_know` gaps, sensitivity, and allowed surfaces. The proposal must expose confidence components and negative evidence. Durable memory writes, skill/doc changes, artifact presentation, and outbound delivery require an explicit Conscious authorization receipt plus any configured operator/policy gate. -See `docs/memory-volunteering-protocol.md` in the plugin docs for the formal schema, threshold defaults, source-type rules, and acceptance probes. +See the [Memory Volunteering Protocol](../../docs/memory-volunteering-protocol.md) for the formal schema, threshold defaults, source-type rules, and acceptance probes. --- diff --git a/tests/test_attention_lifecycle_review_regressions.py b/tests/test_attention_lifecycle_review_regressions.py index 7c2e7b9..7a4d8ba 100644 --- a/tests/test_attention_lifecycle_review_regressions.py +++ b/tests/test_attention_lifecycle_review_regressions.py @@ -100,6 +100,8 @@ def _concurrent_candidate_writer_process(state_dir, mode, started, done, result_ "sensitivity": "private", "allowed_surfaces": ["local"], } + if mode == "coalesce": + event["source_signal_ids"] = ["sig-fictional-concurrency-r2"] result = json.loads(handle_sensorium_ingest_event( event=event, instance="test", @@ -891,6 +893,23 @@ def test_candidate_mutations_serialize_across_processes(writer_mode, tmp_path): "created_at": "2026-06-07T10:00:00Z", "updated_at": "2026-06-07T10:00:00Z", }) + if writer_mode == "coalesce": + for revision in ("r1", "r2"): + store.append_jsonl("signals", { + "id": f"sig-fictional-concurrency-{revision}", + "sensor": "fictional.glasshouse", "source": "artifact", + "kind": "task_result", "source_revision": revision, + "summary": f"Fictional glass-orchid material {revision}", + "artifact_meta": {"source_id": "fictional-catalog", "item_id": "glass-orchid-lock"}, + }) + store.append_jsonl("events", { + "id": "evt-original", "ts": "2026-06-07T10:00:00Z", + "type": "sensor.event.promoted", "kind": "task_result", + "summary": "Fictional glass-orchid material r1", "strength": 0.6, + "source_signal_ids": ["sig-fictional-concurrency-r1"], + "correlation_keys": ["coalesce-target"], + "sensitivity": "private", "allowed_surfaces": ["local"], + }) store.rewrite_jsonl("candidates", rows) ctx = multiprocessing.get_context("spawn") diff --git a/tests/test_cooldown_subject_signature.py b/tests/test_cooldown_subject_signature.py index b8dfba4..4c7ae52 100644 --- a/tests/test_cooldown_subject_signature.py +++ b/tests/test_cooldown_subject_signature.py @@ -11,7 +11,7 @@ What's pinned: 1. A candidate receipt and a saved_residue receipt for the SAME candidate id - share one cooldown bucket. If cand_x was just presented as an active + share one cooldown bucket. If cand_fictional_x was just presented as an active candidate pointer, it cannot be re-surfaced as a saved_residue pointer within the cooldown window. (Pin: candidate ↔ saved_residue cooling blocks the same candidate id.) @@ -27,8 +27,8 @@ 3. Thread receipts and candidate receipts use INDEPENDENT cooldown keys (thread receipt looks up by ``thread_id``; candidate receipt looks up by ``candidate_id``). Presenting a thread that was spawned from - cand_associated does NOT block a future candidate pointer for - cand_associated, because the thread receipt has no ``candidate_id`` + cand_fictional_associated does NOT block a future candidate pointer for + cand_fictional_associated, because the thread receipt has no ``candidate_id`` field — only ``thread_id`` and ``origin_candidate_id``. 4. Cooldown is a TIME-WINDOW preference, not a permanent block. A receipt @@ -60,7 +60,7 @@ def _write_config(state_dir, *, surfaces=("discord", "local"), max_sensitivity=" config = { "allowed_surfaces": list(surfaces), "max_sensitivity": max_sensitivity, - "instance_name": "test", + "instance_name": "fictional-glasshouse", } path = Path(state_dir) / "instance.config.json" path.parent.mkdir(parents=True, exist_ok=True) @@ -70,15 +70,15 @@ def _write_config(state_dir, *, surfaces=("discord", "local"), max_sensitivity=" def _candidate(**overrides): """Active salience candidate (status=candidate) — used for tests 1, 3, 4.""" base = { - "id": "cand_x", + "id": "cand_fictional_x", "status": "candidate", "kind": "relational_salience", "summary": "Cross-type cooldown test candidate: relational salience left open for later", "pressure": 0.82, "sensitivity": "private", "allowed_surfaces": ["discord", "local"], - "created_at": "2026-07-04T08:00:00Z", - "updated_at": "2026-07-04T08:00:00Z", + "created_at": "2026-09-03T14:00:00Z", + "updated_at": "2026-09-03T14:00:00Z", } base.update(overrides) return base @@ -87,55 +87,53 @@ def _candidate(**overrides): def _saved_residue_candidate(**overrides): """Archived Kanban-saved residue — used for tests 1, 2, 4.""" base = { - "id": "cand_x", + "id": "cand_fictional_x", "status": "archived", "kind": "research_source_signal", "pressure": 0.71, - "summary": "Cross-type cooldown test saved residue: archived arXiv collaboration salience", + "summary": "Cross-type cooldown test saved residue: archived Fablearium propagation salience", "sensitivity": "private", "allowed_surfaces": ["discord", "local"], - "created_at": "2026-07-04T08:00:00Z", - "updated_at": "2026-07-04T08:10:00Z", + "created_at": "2026-09-03T14:00:00Z", + "updated_at": "2026-09-03T14:10:00Z", "kanban_settlement": { "decision": "SAVE", - "intake_task_id": "t_intake_x", - "review_task_id": "t_review_x", - "settled_at": "2026-07-04T08:05:00Z", - "reason_label": "reason#x", + "intake_task_id": "t_fictional_intake_x", + "review_task_id": "t_fictional_review_x", + "settled_at": "2026-09-03T14:05:00Z", + "reason_label": "reason#fictional-cooldown-save", }, } base.update(overrides) return base -def _arxiv_candidate(**overrides): +def _glass_orchid_candidate(**overrides): """Mirror tests/test_pointer_doorway_saved_residue.py — used by test 4.""" base = { - "id": "cand_arxiv_regression", + "id": "cand_fictional_glass_orchid_regression", "status": "archived", "kind": "research_source_signal", - "pressure": 0.783, + "pressure": 0.79, "summary": ( - "Research source feed (arXiv cs.AI: agent collaboration / governance): " - "From Signals to Structure: How Memory Architecture Drives Language " - "Emergence in LLM Agents. Authors: Talebirad et al. Abstract: arXiv:2607.00233." + "Fablearium research feed (fictional glass-orchid propagation): Prismatic Petal Atlas. Invented author: Dr. Paper Fern. Synthetic catalog item: glass-orchid-01." ), "correlation_keys": [ - "lane:agent-society", - "source:arxiv-cs-ai-agent-collaboration-governance", - "topic:memory", - "topic:multi-agent", + "lane:fictional-glasshouse", + "source:fablearium-glass-orchid", + "topic:petal-optics", + "topic:seed-trays", ], "sensitivity": "private", "allowed_surfaces": ["discord", "local"], - "created_at": "2026-07-02T04:18:00Z", - "updated_at": "2026-07-02T04:39:17Z", + "created_at": "2026-09-01T10:00:00Z", + "updated_at": "2026-09-01T10:25:00Z", "kanban_settlement": { "decision": "SAVE", - "intake_task_id": "task_intake_saved", - "review_task_id": "task_review_saved", - "settled_at": "2026-07-02T04:29:29Z", - "reason_label": "reason#bdf731842efcbb5b", + "intake_task_id": "task_fictional_intake_saved", + "review_task_id": "task_fictional_review_saved", + "settled_at": "2026-09-01T10:12:00Z", + "reason_label": "reason#fictional-glass-orchid-save", }, } base.update(overrides) @@ -145,18 +143,18 @@ def _arxiv_candidate(**overrides): def _thread(**overrides): """Thread row with explicit origin_candidate_id — used by test 3.""" base = { - "id": "sth_x", + "id": "sth_fictional_x", "status": "dormant", "origin": "candidate", "conscious_task": { - "id": "ctask_x", + "id": "ctask_fictional_x", "request_type": "THINK", - "title": "Cross-type cooldown test thread: review relational salience for cand_associated", - "why": "test", + "title": "Cross-type cooldown test thread: review relational salience for cand_fictional_associated", + "why": "fictional-glasshouse", "expected_decision": "decide", }, - "origin_candidate_id": "cand_associated", - "continuity_summary": ["test"], + "origin_candidate_id": "cand_fictional_associated", + "continuity_summary": ["fictional-glasshouse"], "decision_log": [], "interaction_refs": [], "summary_dirty": False, @@ -164,9 +162,9 @@ def _thread(**overrides): "next_prompt_to_operator": "Take it up?", "sensitivity": "private", "allowed_surfaces": ["discord", "local"], - "created_at": "2026-07-04T08:00:00Z", - "updated_at": "2026-07-04T08:00:00Z", - "expires_at": "2026-07-11T08:00:00Z", + "created_at": "2026-09-03T14:00:00Z", + "updated_at": "2026-09-03T14:00:00Z", + "expires_at": "2026-09-10T14:00:00Z", } base.update(overrides) return base @@ -205,13 +203,13 @@ def test_cooldown_blocks_same_candidate_across_pointer_types(tmp_path): Set-up mirrors the live workflow where salience is first surfaced as an active candidate pointer, then archived (Kanban SAVE). The candidate_id cooldown key is preserved across that transition, so the saved-residue - pathway will not re-surface cand_x within the cooldown window. + pathway will not re-surface cand_fictional_x within the cooldown window. - Pin shape: after a fresh ``pointer.presented`` receipt for cand_x - (pointer_type=candidate), ``_cooldown_open(store, "cand_x", ..., + Pin shape: after a fresh ``pointer.presented`` receipt for cand_fictional_x + (pointer_type=candidate), ``_cooldown_open(store, "cand_fictional_x", ..., id_field="candidate_id")`` returns ``(False, "cooldown_until:...")`` even when the row is now archived+kanban-saved. The visible outcome - is that the saved_residue pathway silently does not return cand_x + is that the saved_residue pathway silently does not return cand_fictional_x while the receipt is fresh. If a future refactor splits candidate vs saved_residue cooldown keys @@ -220,31 +218,31 @@ def test_cooldown_blocks_same_candidate_across_pointer_types(tmp_path): """ from agent_sensorium.pointers import _cooldown_open - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - # Phase 1: cand_x is an active candidate. Surface it as a candidate + # Phase 1: cand_fictional_x is an active candidate. Surface it as a candidate # pointer and record the receipt (the normal pre-LLM round-trip path). store.append_jsonl("candidates", _candidate()) first_pointer = select_attention_pointer(store, surface="discord") assert first_pointer["action"] == "pointer_available" assert first_pointer["pointer_type"] == "candidate" - assert first_pointer["candidate_id"] == "cand_x" + assert first_pointer["candidate_id"] == "cand_fictional_x" receipt = record_pointer_presented( store, first_pointer, session_id="s1", surface="discord", ) assert receipt["type"] == "pointer.presented" assert receipt["subject_kind"] == "candidate" assert receipt["pointer_type"] == "candidate" - assert receipt["candidate_id"] == "cand_x" + assert receipt["candidate_id"] == "cand_fictional_x" # Pin: the candidate receipt alone already blocks the candidate-id - # cooldown for cand_x, regardless of whether cand_x is currently + # cooldown for cand_fictional_x, regardless of whether cand_fictional_x is currently # active or archived. (The candidate pointer pathway re-uses this # exact id_field branch.) open_, reason = _cooldown_open( - store, "cand_x", 120, id_field="candidate_id", + store, "cand_fictional_x", 120, id_field="candidate_id", ) assert open_ is False, ( f"Fresh candidate receipt must keep candidate-id cooldown closed. Got: {(open_, reason)!r}" @@ -253,7 +251,7 @@ def test_cooldown_blocks_same_candidate_across_pointer_types(tmp_path): f"Cooldown reason must carry the until marker. Got: {reason!r}" ) - # Phase 2: the operator archives cand_x with Kanban SAVE — now cand_x + # Phase 2: the operator archives cand_fictional_x with Kanban SAVE — now cand_fictional_x # is eligible for the saved_residue pathway (and the row writes append # to the jsonl alongside the active phase-1 row). store.append_jsonl("candidates", _saved_residue_candidate()) @@ -264,7 +262,7 @@ def test_cooldown_blocks_same_candidate_across_pointer_types(tmp_path): # underlying candidate status, which is exactly the cross-type shared # bucket the audit is flagging. open_after_archive, reason_after = _cooldown_open( - store, "cand_x", 120, id_field="candidate_id", + store, "cand_fictional_x", 120, id_field="candidate_id", ) assert open_after_archive is False, ( f"Cooldown must remain closed across the active→archived transition. " @@ -273,9 +271,9 @@ def test_cooldown_blocks_same_candidate_across_pointer_types(tmp_path): assert reason_after.startswith("cooldown_until:") # Pin through select_attention_pointer: with NO threads, NO active - # candidate rows visible on discord, and ONLY cand_x as a saved + # candidate rows visible on discord, and ONLY cand_fictional_x as a saved # residue, the candidate_id cooldown keeps the saved_residue pathway - # from re-surfacing cand_x. The user-facing outcome is no_pointer + # from re-surfacing cand_fictional_x. The user-facing outcome is no_pointer # (with no usable threads, candidates, or residue candidates). # The pre-existing saved_residue pathway never returns a # cooldown_until reason via this surface — it silently fails through @@ -283,7 +281,7 @@ def test_cooldown_blocks_same_candidate_across_pointer_types(tmp_path): # the candidate_id branch called inside the saved_residue loop # returned False. We assert that directly via _cooldown_open # above, and additionally assert the function actually surfaces - # cand_x as a saved_residue pointer when the cooldown DOES elapse + # cand_fictional_x as a saved_residue pointer when the cooldown DOES elapse # (test_cooldown_elapses_after_window), to close the loop. @@ -296,7 +294,7 @@ def test_cooldown_receipt_records_subject_kind_and_pointer_type(tmp_path): This test pins that the rows do carry them — so a future, narrower cooldown key has the per-row data to filter on. """ - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) @@ -310,8 +308,8 @@ def test_cooldown_receipt_records_subject_kind_and_pointer_type(tmp_path): assert cand_receipt["type"] == "pointer.presented" assert cand_receipt["pointer_type"] == "candidate" assert cand_receipt["subject_kind"] == "candidate" - assert cand_receipt["subject_id"] == "cand_x" - assert cand_receipt["candidate_id"] == "cand_x" + assert cand_receipt["subject_id"] == "cand_fictional_x" + assert cand_receipt["candidate_id"] == "cand_fictional_x" assert cand_receipt["thread_id"] in (None, "") # Pin: the candidate receipt row itself carries the per-row subject @@ -320,7 +318,7 @@ def test_cooldown_receipt_records_subject_kind_and_pointer_type(tmp_path): # filter on. candidate_receipts = [ r for r in store.read_jsonl("decisions") - if r.get("type") == "pointer.presented" and r.get("candidate_id") == "cand_x" + if r.get("type") == "pointer.presented" and r.get("candidate_id") == "cand_fictional_x" ] assert len(candidate_receipts) == 1, candidate_receipts @@ -341,20 +339,20 @@ def test_cooldown_receipt_records_subject_kind_and_pointer_type(tmp_path): "ts": utc_now_iso(), "type": "pointer.presented", "pointer_type": "saved_residue", - "candidate_id": "cand_x", + "candidate_id": "cand_fictional_x", "thread_id": "", "task_id": "", "surface": "discord", "session_id": "s2", "subject_kind": "saved_residue", - "subject_id": "cand_x", - "presented_title": "saved residue for cand_x", + "subject_id": "cand_fictional_x", + "presented_title": "saved residue for cand_fictional_x", } store.append_jsonl("decisions", saved_residue_receipt) receipts = [ r for r in store.read_jsonl("decisions") - if r.get("type") == "pointer.presented" and r.get("candidate_id") == "cand_x" + if r.get("type") == "pointer.presented" and r.get("candidate_id") == "cand_fictional_x" ] assert len(receipts) == 2, receipts @@ -365,14 +363,14 @@ def test_cooldown_receipt_records_subject_kind_and_pointer_type(tmp_path): # distinguish them. assert by_kind["candidate"]["pointer_type"] == "candidate" assert by_kind["saved_residue"]["pointer_type"] == "saved_residue" - assert by_kind["candidate"]["candidate_id"] == "cand_x" - assert by_kind["saved_residue"]["candidate_id"] == "cand_x" + assert by_kind["candidate"]["candidate_id"] == "cand_fictional_x" + assert by_kind["saved_residue"]["candidate_id"] == "cand_fictional_x" # Pin: _recent_pointer_receipts today only filters on candidate_id / # thread_id and returns BOTH kinds. This is the very shape the audit # wants pinned, so a future cross-type decoupling is a deliberate # change (and forces this test's cohort to be updated). - both = _recent_pointer_receipts(store, candidate_id="cand_x") + both = _recent_pointer_receipts(store, candidate_id="cand_fictional_x") assert len(both) == 2, both subject_kinds = sorted(r["subject_kind"] for r in both) assert subject_kinds == ["candidate", "saved_residue"] @@ -382,8 +380,8 @@ def test_cooldown_threads_and_candidates_have_independent_keys(tmp_path): """Pin P2-1c: Thread receipts and candidate receipts use INDEPENDENT cooldown keys. - Presenting a thread that originated from cand_associated does NOT block - a subsequent candidate pointer for cand_associated, because the thread + Presenting a thread that originated from cand_fictional_associated does NOT block + a subsequent candidate pointer for cand_fictional_associated, because the thread receipt has no ``candidate_id`` field — it only carries ``thread_id`` (and ``origin_candidate_id``, which the cooldown key ignores). @@ -400,32 +398,32 @@ def test_cooldown_threads_and_candidates_have_independent_keys(tmp_path): """ from agent_sensorium.pointers import _cooldown_open - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - # Phase A: create a thread spawned from cand_associated (local-only), - # AND an active candidate cand_associated (visible on discord). Plant a + # Phase A: create a thread spawned from cand_fictional_associated (local-only), + # AND an active candidate cand_fictional_associated (visible on discord). Plant a # thread receipt directly so the cooldown-key check is unambiguous. # We can't go through select_attention_pointer here because on the # discord surface the thread isn't visible — which would short-circuit # before the thread's bookkeeping is exercised. So we plant the receipt # the same way a real pre-LLM invocation on the local surface would. - store.append_jsonl("candidates", _candidate(id="cand_associated")) + store.append_jsonl("candidates", _candidate(id="cand_fictional_associated")) store.append_jsonl("threads", _thread(allowed_surfaces=["local"])) # Use the local surface to surface+record the thread receipt. thread_pointer = select_attention_pointer(store, surface="local") assert thread_pointer["action"] == "pointer_available" assert thread_pointer["pointer_type"] == "thread" - assert thread_pointer["thread_id"] == "sth_x" - assert thread_pointer["origin_candidate_id"] == "cand_associated" + assert thread_pointer["thread_id"] == "sth_fictional_x" + assert thread_pointer["origin_candidate_id"] == "cand_fictional_associated" receipt = record_pointer_presented( store, thread_pointer, session_id="s1", surface="local", ) assert receipt["type"] == "pointer.presented" assert receipt["pointer_type"] == "thread" assert receipt["subject_kind"] == "thread" - assert receipt["thread_id"] == "sth_x" + assert receipt["thread_id"] == "sth_fictional_x" # Pin: the thread receipt does NOT set candidate_id. The origin # candidate id travels under ``origin_candidate_id`` and must remain @@ -435,36 +433,36 @@ def test_cooldown_threads_and_candidates_have_independent_keys(tmp_path): "Thread receipts must not set candidate_id — that would couple " "thread cooldowns to the candidate namespace." ) - assert receipt.get("origin_candidate_id") == "cand_associated" + assert receipt.get("origin_candidate_id") == "cand_fictional_associated" # Pin: the internal lookup, asked for the candidate namespace, ignores # thread receipts entirely (because they have no candidate_id). - candidate_namespace = _recent_pointer_receipts(store, candidate_id="cand_associated") + candidate_namespace = _recent_pointer_receipts(store, candidate_id="cand_fictional_associated") assert candidate_namespace == [], ( "Thread receipts must not appear under candidate_id lookups. Got: " f"{candidate_namespace!r}" ) - thread_namespace = _recent_pointer_receipts(store, thread_id="sth_x") + thread_namespace = _recent_pointer_receipts(store, thread_id="sth_fictional_x") assert len(thread_namespace) == 1 assert thread_namespace[0]["subject_kind"] == "thread" - # Pin: the candidate-id cooldown for cand_associated is OPEN even - # though a thread pointer with origin_candidate_id=cand_associated was + # Pin: the candidate-id cooldown for cand_fictional_associated is OPEN even + # though a thread pointer with origin_candidate_id=cand_fictional_associated was # just presented. If a future refactor couples them (e.g., matching on # origin_candidate_id), this assertion will start failing. open_cand, reason_cand = _cooldown_open( - store, "cand_associated", 120, id_field="candidate_id", + store, "cand_fictional_associated", 120, id_field="candidate_id", ) assert open_cand is True, ( - "Thread receipt for sth_x must not contaminate the candidate-id " - f"cooldown for cand_associated. Got: {(open_cand, reason_cand)!r}" + "Thread receipt for sth_fictional_x must not contaminate the candidate-id " + f"cooldown for cand_fictional_associated. Got: {(open_cand, reason_cand)!r}" ) assert reason_cand == "never_presented" # Pin through select_attention_pointer: on the discord surface the # thread is invisible (allowed_surfaces=["local"]), so the threads # branch is empty and the function falls through to the candidate - # branch. The candidate pointer for cand_associated must surface + # branch. The candidate pointer for cand_fictional_associated must surface # cleanly because the thread receipt above has NO candidate_id field. surf = select_attention_pointer( store, @@ -472,11 +470,11 @@ def test_cooldown_threads_and_candidates_have_independent_keys(tmp_path): config={"fallback_when_all_visible_on_cooldown": False}, ) assert surf["action"] == "pointer_available", ( - "Thread receipt for sth_x must NOT block the candidate pointer " - f"for cand_associated on the discord surface. Got: {surf!r}" + "Thread receipt for sth_fictional_x must NOT block the candidate pointer " + f"for cand_fictional_associated on the discord surface. Got: {surf!r}" ) assert surf["pointer_type"] == "candidate" - assert surf["candidate_id"] == "cand_associated" + assert surf["candidate_id"] == "cand_fictional_associated" def test_cooldown_elapses_after_window(tmp_path): @@ -485,7 +483,7 @@ def test_cooldown_elapses_after_window(tmp_path): A receipt whose ``ts`` is older than ``cooldown_minutes`` ago must NOT block subsequent pointer selection. The same item re-surfaces. """ - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) @@ -493,7 +491,7 @@ def test_cooldown_elapses_after_window(tmp_path): # candidate_fallback_enabled=True ensures the saved-residue pathway # does NOT catch us by surprise — we want the candidate pathway to be # the only one that can re-surface. - store.append_jsonl("candidates", _arxiv_candidate()) # saved residue alt + store.append_jsonl("candidates", _glass_orchid_candidate()) # saved residue alt # Plant a candidate receipt whose ts is older than the default 120-min # cooldown window. Use 200 minutes back to be safely past the window. @@ -502,17 +500,17 @@ def test_cooldown_elapses_after_window(tmp_path): store.append_jsonl("decisions", _backdated_receipt( ts=backdated_ts, pointer_type="candidate", - candidate_id="cand_x", + candidate_id="cand_fictional_x", subject_kind="candidate", )) # Pin: the receipt is on disk and visible to the candidate-id lookup. - before = _recent_pointer_receipts(store, candidate_id="cand_x") + before = _recent_pointer_receipts(store, candidate_id="cand_fictional_x") assert len(before) == 1 assert before[0]["ts"] == backdated_ts # Pin: with a 200-minute-old receipt and cooldown_minutes=120 the - # candidate pointer for cand_x must surface again. + # candidate pointer for cand_fictional_x must surface again. surf = select_attention_pointer( store, surface="discord", @@ -526,7 +524,7 @@ def test_cooldown_elapses_after_window(tmp_path): f"re-surfacing. Got: {surf!r}" ) assert surf["pointer_type"] == "candidate" - assert surf["candidate_id"] == "cand_x" + assert surf["candidate_id"] == "cand_fictional_x" # The pointer's stated reason should explicitly call out elapsed time. assert surf["reason"] == "cooldown_elapsed", ( f"Expected cooldown_elapsed reason, got: {surf.get('reason')!r}" @@ -551,9 +549,9 @@ def test_cooldown_elapses_after_window(tmp_path): }, ) # With the saved_residue pathway still enabled and a fresh cooldown on - # cand_x (a candidate), the candidate branch skips cand_x (cooldown + # cand_fictional_x (a candidate), the candidate branch skips cand_fictional_x (cooldown # open=False) but the saved_residue branch _also_ uses candidate-id - # cooldown so cand_arxiv_regression should surface instead. + # cooldown so cand_fictional_glass_orchid_regression should surface instead. if blocked["action"] == "no_pointer": assert "cooldown_until:" in blocked.get("reason", "") else: @@ -561,7 +559,7 @@ def test_cooldown_elapses_after_window(tmp_path): # blocked by candidate-id cooldowns only if they share the id — # they don't, so the saved_residue path surfaces a different item. assert blocked["pointer_type"] in {"candidate", "saved_residue"} - assert blocked["candidate_id"] != "cand_x", ( - "Fresh receipt should block cand_x from re-surfacing as either " + assert blocked["candidate_id"] != "cand_fictional_x", ( + "Fresh receipt should block cand_fictional_x from re-surfacing as either " "candidate or saved_residue pointer." ) diff --git a/tests/test_core_cache_injection.py b/tests/test_core_cache_injection.py new file mode 100644 index 0000000..dee4af5 --- /dev/null +++ b/tests/test_core_cache_injection.py @@ -0,0 +1,265 @@ +"""Synthetic public-base cache migration and raw/projection trust prevention.""" +import json +from pathlib import Path +import sqlite3 +import subprocess +import types + +import pytest +from agent_sensorium import admission, admission_index +from agent_sensorium.admission_index import AdmissionIndex +from agent_sensorium.subconscious import run_subconscious_advisory +from tests.test_core_material_replay import signal, store_at, ingest + +BASE = "6ae45f8b0882c6f92770deb98259946c9316013e" + +def public_base(): + source = subprocess.run(["git", "show", BASE + ":agent_sensorium/admission.py"], + cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, check=True).stdout + module = types.ModuleType("agent_sensorium.synthetic_base_admission") + module.__package__ = "agent_sensorium" + exec(compile(source, "", "exec"), module.__dict__) + return module + +def canonical(store): + # All canonical owners, including SAVE/accounting outputs; disposable + # indexes live outside store.paths and are the only permitted rebuild delta. + return {key: path.read_bytes() for key, path in store.paths.items() if path.exists()} + +def raw_snapshot(store): + return {key: store.read_jsonl(key) for key in ("signals", "events", "decisions", "candidates")} + + +def semantics(plan): + # Cache I/O/progress counters describe different execution paths, not + # admission identity. Compare every domain/admission field unchanged. + metrics = {"progress", "bytes_consumed", "records_consumed", "query_rows", + "materialized_rows", "sqlite_vm_steps"} + return {key: value for key, value in plan.items() if key not in metrics} + +@pytest.mark.parametrize("payload", ["claim", "error", "both", "malformed_cache"]) +def test_public_raw_injection_cannot_suppress_distinct_material(tmp_path, payload): + store = store_at(tmp_path) + first = ingest(store, signal()) + row = signal("forged-wrapper", "r999", "different-item") + row["artifact_meta"]["source_id"] = "different-source" + if payload in {"claim", "both"}: + row["_admission_claim"] = admission._claim_for_signal(store.instance, signal())[0] + if payload in {"error", "both"}: + row["_admission_error"] = "malformed_research_revision" + if payload == "malformed_cache": + row.update(_admission_claim={"item_key": []}, _admission_error=123) + expected = admission._claim_for_signal(store.instance, {k:v for k,v in row.items() if not k.startswith("_admission")}) + assert admission._claim_for_signal(store.instance, row) == expected + before = canonical(store) + result = ingest(store, row) + assert result["duplicate"] is False and result["candidate_id"] != first["candidate_id"] + assert canonical(store) != before + assert store.prepare_admission_index().complete + indexed, error = admission.binding_for_candidate(store, result["candidate_id"]) + assert error is None and indexed["item_key"] == expected[0]["item_key"] + +@pytest.mark.parametrize("producer", ["malformed", "conflicting", "missing_scope", "unknown", "different_source"]) +def test_raw_event_and_snapshot_identity_ignore_reserved_fields(tmp_path, producer): + store = store_at(tmp_path) + row = signal() + if producer == "malformed": + row["source_revision"] = None + elif producer == "conflicting": + row["artifact_meta"]["revision_id"] = "r2" + elif producer == "missing_scope": + row.pop("sensor") + elif producer == "unknown": + row.pop("artifact_meta") + row.pop("source_revision") + else: + row["artifact_meta"]["source_id"] = "other-source" + expected = admission._claim_for_signal(store.instance, row) + row.update(_admission_claim=admission._claim_for_signal(store.instance, signal())[0], _admission_error=None) + assert admission._claim_for_signal(store.instance, row) == expected + event = {"id": "event", "source_signal_ids": [row["id"]]} + identity = admission.event_source_item_identity(store, event, signals=[row]) + assert identity == (None if expected[0] is None else (expected[0]["item_key"], expected[0]["revision_key"])) + candidate = {"id": "candidate", "event_ids": ["event"], "status": "candidate"} + snap = {"signals":[row], "events":[event], "candidates":[candidate], "decisions":[]} + binding, error = admission._base_binding_from_snapshot(store, candidate, snap) + assert error == expected[1] + if expected[0]: + assert binding["item_key"] == expected[0]["item_key"] + elif not error: + assert binding["identity_mode"] == "legacy" + for stream, rows in snap.items(): + for retained in rows: + store.append_jsonl(stream, retained) + assert store.prepare_admission_index().complete + indexed, indexed_error = admission.binding_for_candidate(store, candidate["id"]) + if error: + assert indexed is None + assert admission.build_admission_plan(store)["suppressed_counts"][error] == 1 + else: + assert indexed_error is None and indexed == binding + +@pytest.mark.parametrize("variant", ["r1", "missing", "malformed", "conflicting", "alias", "sha256", "matching_all"]) +@pytest.mark.parametrize("old_generation", [0, 3]) +def test_public_base_schema3_cache_rebuild_matches_canonical(tmp_path, monkeypatch, variant, old_generation): + store = store_at(tmp_path) + created = ingest(store, signal()) + rows = store.read_jsonl("signals") + row = rows[0] + if variant == "missing": + row.pop("source_revision") + elif variant == "malformed": + row["source_revision"] = None + elif variant == "conflicting": + row["artifact_meta"]["revision_id"] = "r2" + elif variant == "alias": + row.pop("source_revision") + row["artifact_meta"]["revision_id"] = "r1" + elif variant == "sha256": + row.pop("source_revision") + row["artifact_meta"]["sha256"] = "r1" + elif variant == "matching_all": + row["artifact_meta"].update(revision_id="r1", sha256="r1") + + store.rewrite_jsonl("signals", rows) + base = public_base() + with monkeypatch.context() as patch: + patch.setattr(admission, "_claim_for_signal", base._claim_for_signal) + patch.setattr(admission, "_base_binding_from_snapshot", base._base_binding_from_snapshot) + patch.setattr(admission_index, "SCHEMA_VERSION", 3) + assert store.prepare_admission_index().complete + with sqlite3.connect(AdmissionIndex(store).path) as conn: + assert conn.execute("PRAGMA user_version").fetchone()[0] == 3 + conn.execute(f"PRAGMA user_version={old_generation}") + before = canonical(store) + assert not store.read_admission_binding(created["candidate_id"]).complete + assert store.prepare_admission_index().complete + with sqlite3.connect(AdmissionIndex(store).path) as conn: + assert conn.execute("PRAGMA user_version").fetchone()[0] == admission_index.SCHEMA_VERSION > 3 + snap = raw_snapshot(store) + expected, error = admission._base_binding_from_snapshot(store, snap["candidates"][0], snap) + warm = admission.build_admission_plan(store) + if error: + assert warm["selection"] is None + assert warm["suppressed_counts"].get(error) == 1 + else: + assert warm["selection"] == expected + AdmissionIndex(store).path.unlink() + cold = admission.build_admission_plan(store) + assert semantics(cold) == semantics(warm) + assert store.prepare_admission_index().complete + assert semantics(admission.build_admission_plan(store)) == semantics(warm) + assert canonical(store) == before + +@pytest.mark.parametrize("revision", ["r1", None]) +@pytest.mark.parametrize("settle", ["drop", "save"]) +def test_settled_base_unversioned_stays_settled_but_r2_is_fresh(tmp_path, monkeypatch, revision, settle): + store = store_at(tmp_path) + created = ingest(store, signal(revision=revision)) + base = public_base() + with monkeypatch.context() as patch: + patch.setattr(admission, "_claim_for_signal", base._claim_for_signal) + patch.setattr(admission, "_base_binding_from_snapshot", base._base_binding_from_snapshot) + patch.setattr(admission_index, "SCHEMA_VERSION", 3) + assert store.prepare_admission_index().complete + old, error = admission.binding_for_candidate(store, created["candidate_id"]) + assert error is None + result = run_subconscious_advisory(store, advisory_output={ + "action": settle.upper(), "rationale":"Synthetic settled source", "event_ids":[], + "candidate_ids":[created["candidate_id"]]}, admission_binding=old, + enabled=True, dry_run=False) + assert result["action"] == settle + assert admission.build_admission_plan(store)["selection"] is None + before = canonical(store) + assert store.prepare_admission_index().complete + assert admission.build_admission_plan(store)["selection"] is None + assert canonical(store) == before + AdmissionIndex(store).path.unlink() + assert admission.build_admission_plan(store)["selection"] is None + assert ingest(store, signal("same-revision", revision))["duplicate"] + assert canonical(store) == before + updated = ingest(store, signal("fresh-r2", "r2")) + assert updated["coalesced"] and updated["candidate_id"] == created["candidate_id"] + assert store.prepare_admission_index().complete + selected = admission.build_admission_plan(store)["selection"] + assert selected and selected["admission_key"] != old["admission_key"] + assert selected["revision_key"] != old["revision_key"] + +@pytest.mark.parametrize("family", ["research", "frontier", "memory", "transition"]) +def test_legitimate_projected_cacheless_and_sql_reads(tmp_path, family): + store = store_at(tmp_path) + row = signal() + if family == "frontier": + row["artifact_meta"] = {"entry_id": "synthetic-entry", "sha256": "r1"} + elif family == "memory": + row.pop("artifact_meta") + row.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider":"synthetic", "bank_id":"fixture", "item_ids":["a"]}) + elif family == "transition": + row.pop("artifact_meta") + row.update(kind="synthetic_pressure", metric_family="fixture", source_revision="1") + created = ingest(store, row) + snap = raw_snapshot(store) + expected, error = admission._base_binding_from_snapshot(store, snap["candidates"][0], snap) + assert error is None + projected = store.read_admission_snapshot() + assert projected.complete + assert "artifact_meta" not in projected.snapshot["signals"][0] + got, error = admission._base_binding_from_snapshot(store, projected.snapshot["candidates"][0], projected.snapshot) + assert got == expected and error is None + # JSON roundtrips erase structural trust; serialized reserved keys cannot + # confer projected-read authority on a raw producer or event join. + serialized = json.loads(json.dumps(projected.snapshot)) + raw, error = admission._base_binding_from_snapshot(store, serialized["candidates"][0], serialized) + assert error is None and raw["identity_mode"] == "legacy" + assert admission.event_source_item_identity(store, projected.snapshot["events"][0], + signals=serialized["signals"]) is None + full_expected, error = admission._binding_from_snapshot(store, snap["candidates"][0], snap) + assert error is None + full_projected, error = admission._binding_from_snapshot( + store, projected.snapshot["candidates"][0], projected.snapshot) + assert error is None and full_projected == full_expected + assert store.prepare_admission_index().complete + assert admission.binding_for_candidate(store, created["candidate_id"]) == (full_expected, None) + + +def test_settled_successor_r1_injected_old_claim_does_not_hide_fresh_r2(tmp_path): + store = store_at(tmp_path) + created = ingest(store, signal()) + old, error = admission.binding_for_candidate(store, created["candidate_id"]) + assert error is None + result = run_subconscious_advisory(store, advisory_output={ + "action":"DROP", "rationale":"Synthetic settled r1", "event_ids":[], + "candidate_ids":[created["candidate_id"]]}, admission_binding=old, + enabled=True, dry_run=False) + assert result["action"] == "drop" + assert admission.build_admission_plan(store)["selection"] is None + row = signal("fresh-r2", "r2") + row["_admission_claim"] = admission._claim_for_signal(store.instance, signal())[0] + updated = ingest(store, row) + assert not updated["duplicate"] and updated["coalesced"] + assert updated["candidate_id"] == created["candidate_id"] + assert store.prepare_admission_index().complete + new = admission.build_admission_plan(store)["selection"] + assert new and new["admission_key"] != old["admission_key"] + assert new["revision_key"] != old["revision_key"] + + +@pytest.mark.parametrize("payload", ["error", "foreign_claim"]) +def test_matching_raw_revision_replays_despite_reserved_payload(tmp_path, monkeypatch, payload): + from agent_sensorium import tools + store = store_at(tmp_path) + original = ingest(store, signal()) + row = signal("replay-wrapper") + if payload == "error": + row["_admission_error"] = "fake-cached-error" + else: + row["_admission_claim"] = admission._claim_for_signal(store.instance, signal(item="foreign"))[0] + def forbidden(*args, **kwargs): + raise AssertionError("Replay must not decay or observe") + monkeypatch.setattr(tools, "_apply_candidate_decay", forbidden) + monkeypatch.setattr(tools, "observe_after_success", forbidden) + before = canonical(store) + replay = ingest(store, row) + assert replay["duplicate"] and replay["candidate_id"] == original["candidate_id"] + assert canonical(store) == before diff --git a/tests/test_core_latent_awareness.py b/tests/test_core_latent_awareness.py new file mode 100644 index 0000000..2cf0c2e --- /dev/null +++ b/tests/test_core_latent_awareness.py @@ -0,0 +1,113 @@ +"""Optional latent projection: synthetic closed-policy and omission proofs.""" +import json + +import pytest + +from agent_sensorium import attention +from agent_sensorium.store import SensoriumStore + +POLICY = {"allowed_surfaces": ["local"], "max_sensitivity": "private"} + + +def thread(identifier="sth-a", state="held"): + return {"id": identifier, "status": state, "sensitivity": "private", + "allowed_surfaces": ["local"], "conscious_task": {"title": "Synthetic thread", "request_type": "THINK"}, + "continuity_summary": ["Synthetic unresolved material"], "source_refs": ["synthetic:one"], + "updated_at": "2026-03-01T00:00:00Z"} + + +def receipt(identifier="sth-a", action="hold", ts="2026-01-01T00:00:00Z"): + return {"thread_id": identifier, "type": "thread.updated", "action": action, + "old_status": "dormant", "new_status": "held" if action == "hold" else "dormant", "ts": ts} + + +def group(rows, decisions=None, **kwargs): + return attention._ongoing_thread_group(rows, decisions or [], surface="local", config=POLICY, + previous_activation_at="2026-01-01T00:00:00Z", **kwargs) + + +@pytest.mark.parametrize("field,value", [ + ("sensitivity", "unknown"), ("sensitivity", []), ("sensitivity", None), + ("allowed_surfaces", "local"), ("allowed_surfaces", [["local"]]), + ("allowed_surfaces", []), ("status", "active"), ("id", None), +]) +def test_latent_visibility_is_closed(field, value): + row = thread() + row[field] = value + assert group([row])["threads"] == [] + + +def test_duplicate_ids_rejected_even_when_other_row_is_hidden(): + visible = thread() + hidden = {**visible, "sensitivity": "unknown", "status": "closed"} + result = group([visible, hidden]) + assert result["threads"] == [] + assert result["ambiguous_duplicate_thread_id_count"] == 1 + + +def test_explicit_hold_is_not_ledger_touch_or_unknown_transition(): + rows = [thread(), thread("sth-b"), thread("sth-c", "dormant")] + decisions = [receipt(), receipt("sth-b", ts="malformed")] + result = group(rows, decisions) + assert [row["hold_choice_provenance"] for row in result["threads"]] == [ + "explicit_recorded_hold", "legacy_or_unknown", "not_applicable"] + first = result["threads"][0] + assert first["receipts"][0]["timestamp_semantics"] == "recorded_transition_not_source_observation" + assert first["receipts"][0]["recorded_change_since_previous_activation"] is False + assert result["threads"][1]["receipts"][0]["recorded_at"] is None + assert result["threads"][1]["receipts"][0]["recorded_change_since_previous_activation"] is None + + +def test_feedback_is_operator_evaluation_not_preference_and_unknown_is_unknown(): + feedback = {"thread_id": "sth-a", "type": "thread.feedback_emitted", "action": "hold", + "outcome": "operator_rejected", "feedback_scope": "operator_evaluation", + "ts": "2026-01-01T00:00:00.123Z", "feedback_signal_id": "synthetic-feedback"} + first = group([thread()], [feedback])["threads"][0] + assert first["operator_evaluation_feedback_status"] == "included" + assert first["receipts"][0]["recorded_at"] == "2026-01-01T00:00:00.123000Z" + assert first["receipts"][0]["recorded_change_since_previous_activation"] is True + assert "not_user_preference" in first["receipts"][0]["timestamp_semantics"] + assert group([thread()], [{**feedback, "outcome": "invented"}])["threads"][0]["operator_evaluation_feedback_status"] == "invalid" + assert group([thread()])["threads"][0]["operator_evaluation_feedback_status"] == "unavailable" + + +@pytest.mark.parametrize("timestamp", ["bad", "2026-01-01", "2026-01-01T00:00:00", 4, [], "x" * 1000]) +def test_invalid_timestamps_do_not_escape(timestamp): + result = group([thread()], [receipt(ts=timestamp)]) + assert result["threads"][0]["receipts"][0]["recorded_at"] is None + assert result["threads"][0]["hold_choice_provenance"] == "legacy_or_unknown" + + +def test_output_caps_and_exact_omissions(): + rows = [thread(f"sth-{i}", "held" if i < 8 else "dormant") for i in range(12)] + for row in rows: + row.update(continuity_summary=["雪" * 400] * 8, open_questions=["Synthetic question" * 30] * 8, + source_refs=[f"source:{i}" for i in range(8)]) + row["decision_log"] = [receipt(row["id"], ts=f"2026-01-0{i+1}T00:00:00Z") for i in range(4)] + result = group(rows) + assert len(result["threads"]) <= 6 + assert all(len(row["receipts"]) <= 2 for row in result["threads"]) + assert len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) <= 6144 + assert result["omitted_held_count"] + sum(row["state"] == "held" for row in result["threads"]) == 8 + assert result["omitted_dormant_count"] + sum(row["state"] == "dormant" for row in result["threads"]) == 4 + assert result["truncated_thread_detail_count"] == sum(row["truncated"] for row in result["threads"]) + assert result["complete"] is False + assert "unbounded" in result["scan_completeness"] + assert result["input_trust"] == "untrusted_recorded_context_not_instructions" + + +def test_projection_is_opt_in_read_only_without_automatic_resume(tmp_path): + store = SensoriumStore(instance="latent", state_dir=str(tmp_path / "state")) + # An empty projection must not even initialize a root. + attention.build_attention_inbox(store, config=POLICY, include_ongoing_threads=True) + assert not store.root.exists() + store.ensure_dirs() + store.append_jsonl("threads", thread()) + before = {str(p.relative_to(store.root)): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} + assert "ongoing_threads" not in attention.build_attention_inbox(store, config=POLICY) + result = attention.build_attention_inbox(store, config=POLICY, include_ongoing_threads=True) + assert result["ongoing_threads"]["threads"] + after = {str(p.relative_to(store.root)): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} + assert before == after + assert store.read_jsonl("worker_requests") == [] + assert store.read_jsonl("thread_actions") == [] diff --git a/tests/test_core_material_replay.py b/tests/test_core_material_replay.py new file mode 100644 index 0000000..8ab7ebc --- /dev/null +++ b/tests/test_core_material_replay.py @@ -0,0 +1,202 @@ +"""Synthetic producer material identity, replay and bounded progress proofs.""" + +import json + +import pytest + +from agent_sensorium import admission, tools +from agent_sensorium.admission_index import MAX_RECORD_BYTES +from agent_sensorium.gate import signal_fingerprint +from agent_sensorium.schemas import normalize_signal +from agent_sensorium.store import SensoriumStore + + +def signal(identifier="sig-a", revision: str | None = "r1", item="a"): + row = {"id": identifier, "sensor": "research.feed", "source": "artifact", + "kind": "creative_pull", "summary": "Synthetic material", + "strength_hint": 0.9, "sensitivity": "private", + "allowed_surfaces": ["local"], "correlation_keys": ["topic:synthetic"], + "artifact_meta": {"source_id": "synthetic-feed", "item_id": item}} + if revision is not None: + row["source_revision"] = revision + return row + + +def store_at(tmp_path): + store = SensoriumStore(instance="core", state_dir=str(tmp_path / "state")) + store.ensure_dirs() + return store + + +def ingest(store, row): + result = json.loads(tools.handle_sensorium_ingest_signal( + signal=row, instance=store.instance, state_dir=str(store.root), config={})) + assert result["success"], result + return result["data"] + + +def snapshot(store): + return {name: path.read_bytes() for name, path in store.paths.items() if path.exists()} + + +@pytest.mark.parametrize("alias", ["source_revision", "revision_id", "sha256"]) +def test_normalized_alias_equality_and_changed_revision(tmp_path, alias): + store = store_at(tmp_path) + first = signal(revision=None) + if alias == "source_revision": + first[alias] = "r1" + else: + first["artifact_meta"][alias] = "r1" + normalized = normalize_signal(first) + assert normalized.get(alias) == first.get(alias) + assert normalized["artifact_meta"] == first["artifact_meta"] + original = ingest(store, first) + replay = signal("sig-wrapper", None) + replay["artifact_meta"]["revision_id"] = "r1" + replay.update(summary="Different wrapper prose", ts="2026-01-02T00:00:00Z") + assert ingest(store, replay)["duplicate"] is True + changed = signal("sig-new", "r2") + changed["correlation_keys"] = [] + updated = ingest(store, changed) + assert updated["duplicate"] is False + assert updated["coalesced"] is True + assert updated["candidate_id"] == original["candidate_id"] + + +@pytest.mark.parametrize("bad", [None, "", 3, [], "r" * 513]) +def test_present_invalid_revision_is_not_missing(bad): + row = signal(revision=None) + row["artifact_meta"]["revision_id"] = bad + claim, error = admission._claim_for_signal("core", row) + assert claim is None and error == "malformed_research_revision" + + +@pytest.mark.parametrize("family", ["research", "frontier"]) +def test_conflicting_revision_aliases_and_identity_mixtures(family): + row = signal() + if family == "frontier": + row["artifact_meta"] = {"entry_id": "a", "sha256": "r1"} + row["artifact_meta"]["revision_id"] = "r2" + assert admission._claim_for_signal("core", row)[0] is None + mixed = signal() + mixed["artifact_meta"].update(entry_id="a", sha256="r1") + assert admission._claim_for_signal("core", mixed)[1] == "ambiguous_source_identity" + + +def test_missing_revision_remains_unversioned(): + one = signal(revision=None) + two = signal("other", None) + two.update(ts="2026-02-01T00:00:00Z", summary="Rephrased synthetic observation") + assert admission.signal_source_item_identity("core", one) == admission.signal_source_item_identity("core", two) + assert admission.signal_source_item_identity("core", one) != admission.signal_source_item_identity("core", signal()) + assert signal_fingerprint(one) != signal_fingerprint({**one, "artifact_meta": {**one["artifact_meta"], "revision_id": "r1"}}) + + +@pytest.mark.parametrize("shape", ["dangling", "unknown", "mixed_item", "mixed_revision", "duplicate_id", "invalid_refs"]) +def test_event_join_requires_complete_unique_claims(tmp_path, shape): + store = store_at(tmp_path) + rows = [signal()] + refs = ["sig-a"] + if shape == "dangling": + refs += ["missing"] + elif shape == "unknown": + rows += [{**signal("unknown"), "artifact_meta": {}}] + refs += ["unknown"] + elif shape in {"mixed_item", "mixed_revision"}: + rows += [signal("sig-b", "r2" if shape == "mixed_revision" else "r1", "b" if shape == "mixed_item" else "a")] + refs += ["sig-b"] + elif shape == "duplicate_id": + rows += [signal(item="b")] + else: + refs = "sig-a" + assert admission.event_source_item_identity(store, {"source_signal_ids": refs}, signals=rows) is None + + +def test_material_replay_is_domain_write_and_observer_free(tmp_path, monkeypatch): + store = store_at(tmp_path) + first = ingest(store, signal()) + def forbidden(*args, **kwargs): + raise AssertionError("Replay cannot decay or observe") + monkeypatch.setattr(tools, "_apply_candidate_decay", forbidden) + monkeypatch.setattr(tools, "observe_after_success", forbidden) + before = snapshot(store) + assert ingest(store, {**signal("wrapper"), "summary": "Wrapper paraphrase"})["duplicate"] + event = dict(store.read_jsonl("events")[0]) + event.update(id="evt-wrapper", summary="Event paraphrase") + replay = json.loads(tools.handle_sensorium_ingest_event( + event=event, instance=store.instance, state_dir=str(store.root), config={})) + assert replay["data"]["duplicate"] and replay["data"]["candidate_id"] == first["candidate_id"] + assert snapshot(store) == before + + +def test_unknown_same_topic_does_not_merge(tmp_path): + store = store_at(tmp_path) + one = signal() + one.pop("artifact_meta") + two = {**one, "id": "sig-two", "summary": "Different synthetic material"} + first, second = ingest(store, one), ingest(store, two) + assert first["candidate_id"] != second["candidate_id"] + + +@pytest.mark.parametrize("corruption", ["dangling_event", "dangling_signal", "unknown_signal"]) +def test_changed_revision_cannot_merge_incomplete_prior(tmp_path, corruption): + store = store_at(tmp_path) + first = ingest(store, signal()) + if corruption == "dangling_event": + rows = store.read_jsonl("candidates") + rows[0]["event_ids"].append("missing") + store.rewrite_jsonl("candidates", rows) + else: + events = store.read_jsonl("events") + events[0]["source_signal_ids"].append("unknown") + store.rewrite_jsonl("events", events) + if corruption == "unknown_signal": + store.append_jsonl("signals", {"id": "unknown", "sensor": "synthetic", "source": "artifact"}) + assert ingest(store, signal("changed", "r2"))["candidate_id"] != first["candidate_id"] + + +def test_index_consumes_multiple_large_complete_records(tmp_path): + store = store_at(tmp_path) + for index in range(3): + row = signal(f"sig-{index}", item=str(index)) + row["padding"] = "x" * (MAX_RECORD_BYTES - 1024) + store.append_jsonl("signals", row) + result = store.prepare_admission_index(scan_bytes=4 * 1024 * 1024) + assert result.complete and result.records_consumed == 3 + assert result.bytes_consumed <= 4 * 1024 * 1024 + assert result.progress["signals"]["complete"] + + +def test_index_partial_tail_and_budget_continuation(tmp_path): + store = store_at(tmp_path) + store.append_jsonl("signals", signal()) + path = store.paths["signals"] + with path.open("ab") as handle: + handle.write(json.dumps(signal("tail", "r2")).encode()) + result = store.prepare_admission_index(scan_bytes=4096) + assert not result.complete and result.state != "invalid" + assert result.records_consumed == 1 + with path.open("ab") as handle: + handle.write(b"\n") + second = store.prepare_admission_index(scan_bytes=4096) + assert second.complete and second.records_consumed == 1 + third = store.prepare_admission_index(scan_bytes=1) + assert not third.complete + assert third.bytes_consumed <= 1 + + +def test_index_budget_makes_monotone_progress(tmp_path): + store = store_at(tmp_path) + for index in range(9): + row = signal(f"sig-{index}", item=str(index)) + row["padding"] = "x" * 2048 + store.append_jsonl("signals", row) + offsets = [] + for _ in range(12): + result = store.prepare_admission_index(scan_bytes=4096) + assert result.bytes_consumed <= 4096 + offsets.append(result.progress["signals"]["offset"]) + if result.complete: + break + assert result.complete + assert offsets == sorted(set(offsets)) diff --git a/tests/test_feedback.py b/tests/test_feedback.py index da7948b..4c2e03f 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -456,15 +456,31 @@ class TestFeedbackCoalescePath: def test_feedback_meta_set_on_coalesced_candidate(self, state_dir): normal_sig = { - "sensor": "test", - "source": "manual", + "sensor": "feedback_loop", + "source_revision": "r1", + "artifact_meta": {"source_id": "fictional-feedback-feed", "item_id": "glass-orchid-01"}, + "source": "feedback", "kind": "task_result", "summary": "Original observation", "strength_hint": 0.9, "correlation_keys": ["project-alpha"], } - handle_sensorium_ingest_signal( - signal=normal_sig, instance="test", state_dir=state_dir + # Retain a source-owned r1 event without feedback metadata; r2 arrives + # through the feedback producer and supplies the causal metadata. + from agent_sensorium.tools import handle_sensorium_ingest_event + normal_sig["id"] = "sig-fictional-feedback-r1" + store = SensoriumStore(instance="test", state_dir=state_dir) + store.append_jsonl("signals", normal_sig) + handle_sensorium_ingest_event( + event={ + "id": "evt-fictional-feedback-r1", "ts": "2001-02-03T10:00:00Z", + "type": "sensor.event.promoted", "kind": "task_result", + "summary": normal_sig["summary"], "strength": 0.9, + "source_signal_ids": [normal_sig["id"]], + "correlation_keys": normal_sig["correlation_keys"], + "sensitivity": "private", "allowed_surfaces": ["local"], + }, + instance="test", state_dir=state_dir, ) store = SensoriumStore(instance="test", state_dir=state_dir) candidates_before = store.read_jsonl("candidates") @@ -472,8 +488,10 @@ def test_feedback_meta_set_on_coalesced_candidate(self, state_dir): assert "feedback_meta" not in candidates_before[0] feedback_sig = _feedback_signal( + source_revision="r2", + artifact_meta={"source_id": "fictional-feedback-feed", "item_id": "glass-orchid-01"}, kind="task_result", - summary="Feedback on same topic", + summary="Feedback on fictional glass-orchid revision r2", strength_hint=0.9, correlation_keys=["project-alpha"], caused_by={"thread_id": "sth_abc123"}, diff --git a/tests/test_pointer_doorway_saved_residue.py b/tests/test_pointer_doorway_saved_residue.py index 8e46aa9..d42dba3 100644 --- a/tests/test_pointer_doorway_saved_residue.py +++ b/tests/test_pointer_doorway_saved_residue.py @@ -1,30 +1,10 @@ -"""Focused regression tests for the false-doorway / saved-salience bug. - -Acceptance criteria (from the live repair task): - - (a) The surface-facing pointer/doorway must advertise an actually openable - thread/artifact OR use honest wording for candidate-only salience. - (b) ``open`` latest/candidate semantics can recover relevant candidate - details when the allowed surface permits, OR the doorway avoids saying - "open thread". - (c) Relevant research_source_signal candidates with a kanban SAVE must not - be silently suppressed away from conscious access; an honest - saved-candidate state should be visible. - (d) These tests cover the exact mismatch shape: a real-world profile - state where a research_source_signal candidate existed with a - kanban SAVE settlement and no thread was minted, and the live pointer - was offering a non-existent thread. - -The bug shape was: - - multiple source signals coalesced into an archived candidate with - kanban_settlement.decision="SAVE" and intake/review task references. - - status correctly said no openable thread existed (all were archived). - - pre_LLM pointer said "I have something for you" (template) → human-facing - line "I have a doorway for the arXiv research" → assistant invented - "open thread X" → opening latest returned "Thread 'latest' not found". - - The candidate was archived away from conscious access. - -These tests reproduce that state and assert the new honest behavior. +"""Wholly fictional glass-orchid regression for candidate-only doorways. + +An invented Fablearium catalog item is archived with a Kanban SAVE and no +thread. An invented operator correction requests honest candidate recovery. +The tests preserve pointer precedence, exact opening, settlement visibility, +surface restrictions and the prohibition on advertising a nonexistent thread. +No profile episode, source citation or conversation is reproduced. """ from __future__ import annotations @@ -47,33 +27,31 @@ # ---------- helpers ---------- -def _arxiv_candidate(**overrides): +def _glass_orchid_candidate(**overrides): base = { - "id": "cand_arxiv_regression", + "id": "cand_fictional_glass_orchid_regression", "status": "archived", "kind": "research_source_signal", - "pressure": 0.783, + "pressure": 0.79, "summary": ( - "Research source feed (arXiv cs.AI: agent collaboration / governance): " - "From Signals to Structure: How Memory Architecture Drives Language " - "Emergence in LLM Agents. Authors: Talebirad et al. Abstract: arXiv:2607.00233." + "Fablearium research feed (fictional glass-orchid propagation): Prismatic Petal Atlas. Invented author: Dr. Paper Fern. Synthetic catalog item: glass-orchid-01." ), "correlation_keys": [ - "lane:agent-society", - "source:arxiv-cs-ai-agent-collaboration-governance", - "topic:memory", - "topic:multi-agent", + "lane:fictional-glasshouse", + "source:fablearium-glass-orchid", + "topic:petal-optics", + "topic:seed-trays", ], "sensitivity": "private", "allowed_surfaces": ["local", "discord"], - "created_at": "2026-07-02T04:18:00Z", - "updated_at": "2026-07-02T04:39:17Z", + "created_at": "2026-09-01T10:00:00Z", + "updated_at": "2026-09-01T10:25:00Z", "kanban_settlement": { "decision": "SAVE", - "intake_task_id": "task_intake_saved", - "review_task_id": "task_review_saved", - "settled_at": "2026-07-02T04:29:29Z", - "reason_label": "reason#bdf731842efcbb5b", + "intake_task_id": "task_fictional_intake_saved", + "review_task_id": "task_fictional_review_saved", + "settled_at": "2026-09-01T10:12:00Z", + "reason_label": "reason#fictional-glass-orchid-save", }, } base.update(overrides) @@ -82,22 +60,22 @@ def _arxiv_candidate(**overrides): def _correction_candidate(**overrides): base = { - "id": "cand_user_correction", + "id": "cand_fictional_user_correction", "status": "candidate", "kind": "explicit_correction", - "pressure": 0.716, + "pressure": 0.72, "summary": ( - "The user corrected Sensorium behavior: do not offer/open a thread " - "when no openable thread exists; highly relevant arXiv agent " - "collaboration/governance salience should remain consciously accessible." + "The fictional glasshouse operator requests: do not offer/open a thread " + "when no openable thread exists; highly relevant Fablearium glass-orchid " + "propagation/petal-optics salience should remain consciously accessible." ), "correlation_keys": [ "active-session", "surface:discord", "explicit_correction", ], "sensitivity": "private", "allowed_surfaces": ["local", "discord"], - "created_at": "2026-07-02T05:00:00Z", - "updated_at": "2026-07-02T05:00:00Z", + "created_at": "2026-09-01T11:00:00Z", + "updated_at": "2026-09-01T11:00:00Z", } base.update(overrides) return base @@ -107,7 +85,7 @@ def _write_config(state_dir, *, surfaces=("discord", "local")): config = { "allowed_surfaces": list(surfaces), "max_sensitivity": "private", - "instance_name": "test", + "instance_name": "fictional-glasshouse", } path = Path(state_dir) / "instance.config.json" path.parent.mkdir(parents=True, exist_ok=True) @@ -115,22 +93,22 @@ def _write_config(state_dir, *, surfaces=("discord", "local")): def _seed_state(tmp_path): - """Reproduce the exact mismatch shape from a real-world profile.""" - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + """Construct the fictional saved-item and active-correction scenario.""" + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - # All threads are archived (matching the live state of 25 archived). - store.append_jsonl("candidates", _arxiv_candidate()) + # No threads are created in this wholly fictional scenario. + store.append_jsonl("candidates", _glass_orchid_candidate()) store.append_jsonl("candidates", _correction_candidate()) return store -def _seeded_with_arxiv(tmp_path): +def _seeded_with_glass_orchid(tmp_path): """Like _seed_state but only the saved-residue is present (no active).""" - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - store.append_jsonl("candidates", _arxiv_candidate()) + store.append_jsonl("candidates", _glass_orchid_candidate()) return store @@ -147,22 +125,22 @@ def test_pointer_with_no_threads_and_only_saved_residue_says_so_honestly(tmp_pat the surface-facing pointer must fall back to the saved-residue pathway and honestly say it is a saved residue, not a thread. """ - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - store.append_jsonl("candidates", _arxiv_candidate()) + store.append_jsonl("candidates", _glass_orchid_candidate()) pointer = select_attention_pointer(store, surface="discord") assert pointer["action"] == "pointer_available" assert pointer["pointer_type"] == "saved_residue" - assert pointer["candidate_id"] == "cand_arxiv_regression" + assert pointer["candidate_id"] == "cand_fictional_glass_orchid_regression" # Honest copy: claim "not an openable thread", do not claim a thread. assert "not an openable thread" in pointer["invitation"].lower() assert pointer["invitation"].lower().startswith("i previously saved") # Linked intake visible. assert pointer["settlement_decision"] == "SAVE" - assert pointer["intake_task_id"] == "task_intake_saved" - assert pointer["review_task_id"] == "task_review_saved" + assert pointer["intake_task_id"] == "task_fictional_intake_saved" + assert pointer["review_task_id"] == "task_fictional_review_saved" # Pointer context must explicitly call out that this is NOT a thread, while # using the exact candidate id for recovery. Calling status here is unsafe: # the pre-LLM hook has already recorded a pointer receipt, so cooldown @@ -170,7 +148,7 @@ def test_pointer_with_no_threads_and_only_saved_residue_says_so_honestly(tmp_pat context = pointer_context_for_llm(pointer) assert "Pointer type: saved_residue" in context assert "NOT an openable thread" in context - assert 'sensorium(action="open", surface="discord", id="cand_arxiv_regression")' in context + assert 'sensorium(action="open", surface="discord", id="cand_fictional_glass_orchid_regression")' in context assert "do not call status as the primary lookup" in context @@ -181,35 +159,35 @@ def test_pointer_falls_back_to_active_candidate_before_saved_residue(tmp_path): store = _seed_state(tmp_path) pointer = select_attention_pointer(store, surface="discord") assert pointer["pointer_type"] == "candidate" - assert pointer["candidate_id"] == "cand_user_correction" + assert pointer["candidate_id"] == "cand_fictional_user_correction" def test_pointer_prefers_thread_when_present(tmp_path): """A real thread pointer wins over candidates and saved-residue.""" - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) store.append_jsonl("threads", { - "id": "sth_x", + "id": "sth_fictional_x", "status": "dormant", "origin": "candidate", "conscious_task": { - "id": "ctask_x", + "id": "ctask_fictional_x", "request_type": "THINK", - "title": "Review arXiv agent collaboration governance item", - "why": "test", + "title": "Review Fablearium glass-orchid propagation petal optics item", + "why": "fictional-glasshouse", "expected_decision": "decide", }, - "created_at": "2026-07-02T05:00:00Z", - "updated_at": "2026-07-02T05:00:00Z", + "created_at": "2026-09-01T11:00:00Z", + "updated_at": "2026-09-01T11:00:00Z", "sensitivity": "private", "allowed_surfaces": ["discord"], }) - store.append_jsonl("candidates", _arxiv_candidate()) + store.append_jsonl("candidates", _glass_orchid_candidate()) pointer = select_attention_pointer(store, surface="discord") assert pointer["pointer_type"] == "thread" - assert pointer["thread_id"] == "sth_x" + assert pointer["thread_id"] == "sth_fictional_x" # Honest copy: thread pointer says "conscious thread waiting", never # claims something is "not openable" the way a candidate pointer does. context = pointer_context_for_llm(pointer) @@ -223,7 +201,7 @@ def test_pointer_prefers_thread_when_present(tmp_path): def test_open_thread_latest_returns_honest_not_found_when_no_thread(tmp_path): _seeded_with_active_and_saved(tmp_path) raw = handle_sensorium_thread_open( - instance="test", state_dir=str(tmp_path), thread_id="latest", surface="discord", + instance="fictional-glasshouse", state_dir=str(tmp_path), thread_id="latest", surface="discord", ) payload = json.loads(raw) assert payload["success"] is False @@ -233,13 +211,13 @@ def test_open_thread_latest_returns_honest_not_found_when_no_thread(tmp_path): def test_open_candidate_by_id_recovers_archived_saved_residue(tmp_path): - """The exact live mismatch: opening a candidate by id must return the + """In the fictional scenario, opening a candidate by id must return the saved-residue candidate capsule, not a fake thread. """ _seed_state(tmp_path) raw = handle_sensorium_candidate_open( - instance="test", state_dir=str(tmp_path), - candidate_id="cand_arxiv_regression", surface="discord", + instance="fictional-glasshouse", state_dir=str(tmp_path), + candidate_id="cand_fictional_glass_orchid_regression", surface="discord", ) payload = json.loads(raw) assert payload["success"] is True @@ -247,50 +225,50 @@ def test_open_candidate_by_id_recovers_archived_saved_residue(tmp_path): # Critical honesty markers — these are what the agent must see. assert data["object_kind"] == "candidate" assert data["is_openable_thread"] is False - assert data["candidate_id"] == "cand_arxiv_regression" + assert data["candidate_id"] == "cand_fictional_glass_orchid_regression" assert data["kind"] == "research_source_signal" # Durability: the kanban settlement block is the trace. assert data["kanban_settlement"]["decision"] == "SAVE" - assert data["kanban_settlement"]["intake_task_id"] == "task_intake_saved" - assert data["kanban_settlement"]["review_task_id"] == "task_review_saved" - # Honest title carries the agent-society/arXiv topic markers, so the + assert data["kanban_settlement"]["intake_task_id"] == "task_fictional_intake_saved" + assert data["kanban_settlement"]["review_task_id"] == "task_fictional_review_saved" + # Honest title carries the fictional-glasshouse/Fablearium topic markers, so the # conscious layer can act on it without further decoding. - assert "arXiv" in data["title"] - assert "agent collaboration" in data["title"].lower() or "governance" in data["title"].lower() + assert "Fablearium" in data["title"] + assert "glass-orchid propagation" in data["title"].lower() or "petal optics" in data["title"].lower() def test_open_candidate_latest_prefers_active_then_saved_residue(tmp_path): _seed_state(tmp_path) raw = handle_sensorium_candidate_open( - instance="test", state_dir=str(tmp_path), + instance="fictional-glasshouse", state_dir=str(tmp_path), candidate_id="latest", surface="discord", ) payload = json.loads(raw) assert payload["success"] is True - assert payload["data"]["candidate_id"] == "cand_user_correction" + assert payload["data"]["candidate_id"] == "cand_fictional_user_correction" def test_open_candidate_latest_falls_back_to_saved_when_no_active(tmp_path): - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - store.append_jsonl("candidates", _arxiv_candidate()) # only saved residue + store.append_jsonl("candidates", _glass_orchid_candidate()) # only saved residue raw = handle_sensorium_candidate_open( - instance="test", state_dir=str(tmp_path), + instance="fictional-glasshouse", state_dir=str(tmp_path), candidate_id="latest", surface="discord", ) payload = json.loads(raw) assert payload["success"] is True - assert payload["data"]["candidate_id"] == "cand_arxiv_regression" + assert payload["data"]["candidate_id"] == "cand_fictional_glass_orchid_regression" assert payload["data"]["is_openable_thread"] is False def test_open_candidate_for_disallowed_surface_refuses(tmp_path): _seed_state(tmp_path) raw = handle_sensorium_candidate_open( - instance="test", state_dir=str(tmp_path), - candidate_id="cand_arxiv_regression", + instance="fictional-glasshouse", state_dir=str(tmp_path), + candidate_id="cand_fictional_glass_orchid_regression", surface="twitter", # not in any config ) payload = json.loads(raw) @@ -303,10 +281,10 @@ def test_open_candidate_for_disallowed_surface_refuses(tmp_path): def test_saved_residue_fallback_can_be_disabled(tmp_path): """Operators can opt out of saved-residue promotion via config.""" - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - store.append_jsonl("candidates", _arxiv_candidate()) + store.append_jsonl("candidates", _glass_orchid_candidate()) pointer = select_attention_pointer( store, surface="discord", @@ -322,27 +300,27 @@ def test_saved_residue_pointer_includes_kanban_settlement_block(): """ pointer = { "pointer_type": "saved_residue", - "candidate_id": "cand_arxiv_regression", - "title": "Research source feed (arXiv cs.AI: agent collaboration / governance)", + "candidate_id": "cand_fictional_glass_orchid_regression", + "title": "Research source feed (Fablearium: glass-orchid propagation / petal optics)", "surface": "discord", "settlement_decision": "SAVE", - "intake_task_id": "task_intake_saved", - "review_task_id": "task_review_saved", + "intake_task_id": "task_fictional_intake_saved", + "review_task_id": "task_fictional_review_saved", "kanban_settlement": { "decision": "SAVE", - "intake_task_id": "task_intake_saved", - "review_task_id": "task_review_saved", - "settled_at": "2026-07-02T04:29:29Z", - "reason_label": "reason#bdf731842efcbb5b", + "intake_task_id": "task_fictional_intake_saved", + "review_task_id": "task_fictional_review_saved", + "settled_at": "2026-09-01T10:12:00Z", + "reason_label": "reason#fictional-glass-orchid-save", }, "invitation": ( - "I previously saved a salience residue (Kanban SAVE): arXiv governance. " + "I previously saved a salience residue (Kanban SAVE): Fablearium petal optics. " "This is not an openable thread — say 'check saved residue' to recap." ), } context = pointer_context_for_llm(pointer) assert "Kanban SAVE" in context - assert "task_intake_saved" in context + assert "task_fictional_intake_saved" in context # ---------- end-to-end live dispatch via the plugin handler ---------- @@ -357,10 +335,10 @@ def test_plugin_handler_routes_open_to_candidate_when_id_prefixes_cand(tmp_path, import agent_sensorium.store as store_module monkeypatch.setattr(store_module, "_DEFAULT_BASE", str(tmp_path / "implicit-state")) - store = SensoriumStore(instance="test") + store = SensoriumStore(instance="fictional-glasshouse") store.ensure_dirs() _write_config(store.root) - store.append_jsonl("candidates", _arxiv_candidate()) + store.append_jsonl("candidates", _glass_orchid_candidate()) captured: dict = {} @@ -382,30 +360,30 @@ def register_skill(self, *a, **kw): raw = handler({ "action": "open", - "id": "cand_arxiv_regression", + "id": "cand_fictional_glass_orchid_regression", "surface": "discord", - "instance": "test", + "instance": "fictional-glasshouse", "state_dir": str(tmp_path), }) payload = json.loads(raw) assert payload["success"] is True assert payload["data"]["object_kind"] == "candidate" - assert payload["data"]["candidate_id"] == "cand_arxiv_regression" + assert payload["data"]["candidate_id"] == "cand_fictional_glass_orchid_regression" assert payload["data"]["is_openable_thread"] is False - # The arXiv settlement block is visible to the conscious layer. - assert payload["data"]["kanban_settlement"]["intake_task_id"] == "task_intake_saved" + # The Fablearium settlement block is visible to the conscious layer. + assert payload["data"]["kanban_settlement"]["intake_task_id"] == "task_fictional_intake_saved" def test_plugin_handler_status_does_not_invent_thread_when_only_saved_residue(tmp_path): """The handle_sensorium status path must stay consistent with the new pointer shape: when no dormant/held thread exists, top_threads is empty. """ - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path) - store.append_jsonl("candidates", _arxiv_candidate()) + store.append_jsonl("candidates", _glass_orchid_candidate()) - raw = handle_sensorium_status(instance="test", state_dir=str(tmp_path)) + raw = handle_sensorium_status(instance="fictional-glasshouse", state_dir=str(tmp_path)) payload = json.loads(raw)["data"] assert payload["counts"]["dormant_threads"] == 0 assert payload["counts"]["held_threads"] == 0 diff --git a/tests/test_probe_audit.py b/tests/test_probe_audit.py index 6258e50..3569b8b 100644 --- a/tests/test_probe_audit.py +++ b/tests/test_probe_audit.py @@ -153,10 +153,12 @@ def test_correlation_coalesces_candidates(self, state_dir): strength_hint=0.9, correlation_keys=["shared_key"], ) sig2 = operator_signal( - summary="Second correction same topic", kind="user_correction", + summary="Second correction to fictional glass-orchid revision r2", kind="user_correction", strength_hint=0.9, correlation_keys=["shared_key"], ) + sig1.update(source_revision="r1", artifact_meta={"source_id": "fictional-operator-feed", "item_id": "glass-orchid-correlation"}) handle_sensorium_ingest_signal(signal=sig1, instance="test", state_dir=state_dir) + sig2.update(source_revision="r2", artifact_meta={"source_id": "fictional-operator-feed", "item_id": "glass-orchid-correlation"}) handle_sensorium_ingest_signal(signal=sig2, instance="test", state_dir=state_dir) store = SensoriumStore(instance="test", state_dir=state_dir) @@ -169,6 +171,7 @@ def test_coalesced_candidate_pressure_increases(self, state_dir): summary="Pressure test A", kind="user_correction", strength_hint=0.9, correlation_keys=["pressure_key"], ) + sig1.update(source_revision="r1", artifact_meta={"source_id": "fictional-operator-feed", "item_id": "glass-orchid-pressure"}) raw1 = handle_sensorium_ingest_signal(signal=sig1, instance="test", state_dir=state_dir) cand_id = json.loads(raw1)["data"]["candidate_id"] @@ -179,6 +182,7 @@ def test_coalesced_candidate_pressure_increases(self, state_dir): summary="Pressure test B", kind="user_correction", strength_hint=0.9, correlation_keys=["pressure_key"], ) + sig2.update(source_revision="r2", artifact_meta={"source_id": "fictional-operator-feed", "item_id": "glass-orchid-pressure"}) handle_sensorium_ingest_signal(signal=sig2, instance="test", state_dir=state_dir) after = [c for c in store.read_jsonl("candidates") if c["id"] == cand_id][0] diff --git a/tests/test_prospective_owner_matrix_tools.py b/tests/test_prospective_owner_matrix_tools.py index 203f277..a20fbbf 100644 --- a/tests/test_prospective_owner_matrix_tools.py +++ b/tests/test_prospective_owner_matrix_tools.py @@ -91,11 +91,16 @@ def _invoke(operation: str, root: Path) -> tuple[str, list[str]]: signal=_signal("sig-promoted-create"), instance="matrix", state_dir=state_dir ), ["source_observed", "candidate_updated"] if operation == "promoted_coalesce": + source_a = _signal("sig-coalesce-a") + source_b = _signal("sig-coalesce-b", summary="deterministic production owner fixture second source") + for row, revision in ((source_a, "r1"), (source_b, "r2")): + row["artifact_meta"] = {"source_id": "synthetic-matrix", "item_id": "coalesce"} + row["source_revision"] = revision first = json.loads(tools.handle_sensorium_ingest_signal( - signal=_signal("sig-coalesce-a"), instance="matrix", state_dir=state_dir + signal=source_a, instance="matrix", state_dir=state_dir )) second = tools.handle_sensorium_ingest_signal( - signal=_signal("sig-coalesce-b", summary="deterministic production owner fixture second source"), instance="matrix", state_dir=state_dir + signal=source_b, instance="matrix", state_dir=state_dir ) second_data = json.loads(second)["data"] # This proves the production coalescer selected the same candidate, not @@ -105,6 +110,13 @@ def _invoke(operation: str, root: Path) -> tuple[str, list[str]]: assert len(SensoriumStore(instance="matrix", state_dir=state_dir).read_jsonl("candidates")) == 1 return second, ["source_observed", "candidate_updated", "source_observed", "candidate_updated"] if operation == "trusted_event_create_update": + store = SensoriumStore(instance="matrix", state_dir=state_dir) + store.ensure_dirs() + for suffix, revision in (("a", "r1"), ("b", "r2")): + row = _signal("sig-evt-trusted-" + suffix, kind="task_result") + row["artifact_meta"] = {"source_id": "synthetic-matrix", "item_id": "trusted-event"} + row["source_revision"] = revision + store.append_jsonl("signals", row) first = json.loads(tools.handle_sensorium_ingest_event( event=_event("evt-trusted-a"), instance="matrix", state_dir=state_dir )) diff --git a/tests/test_status_reference_id.py b/tests/test_status_reference_id.py index 42b4672..2bfa077 100644 --- a/tests/test_status_reference_id.py +++ b/tests/test_status_reference_id.py @@ -15,10 +15,8 @@ top_threads/top_candidates shape is unchanged. (Test 6 below locks this down — it MUST pass today and continue passing after the fix.) -These tests reproduce the live P0-2 audit mismatch where the conscious layer -saw only a rotating top-N and had no way to recover "the subject the prior -pointer presented" without guessing. The fix is a single optional parameter; -the tests below pin the contract. +These wholly fictional glass-orchid fixtures distinguish exact-subject +recovery from rotating top-N selection. No live profile episode is reproduced. """ from __future__ import annotations @@ -36,34 +34,32 @@ # ---------- helpers (mirror test_pointer_doorway_saved_residue.py) ---------- -def _arxiv_candidate(**overrides): +def _glass_orchid_candidate(**overrides): """Archived research_source_signal candidate with a kanban SAVE settlement.""" base = { - "id": "cand_arxiv_status_ref", + "id": "cand_fictional_glass_orchid_status_ref", "status": "archived", "kind": "research_source_signal", - "pressure": 0.783, + "pressure": 0.79, "summary": ( - "Research source feed (arXiv cs.AI: agent collaboration / governance): " - "From Signals to Structure: How Memory Architecture Drives Language " - "Emergence in LLM Agents. Authors: Talebirad et al. Abstract: arXiv:2607.00233." + "Fablearium research feed (fictional glass-orchid propagation): Prismatic Petal Atlas. Invented author: Dr. Paper Fern. Synthetic catalog item: glass-orchid-01." ), "correlation_keys": [ - "lane:agent-society", - "source:arxiv-cs-ai-agent-collaboration-governance", - "topic:memory", - "topic:multi-agent", + "lane:fictional-glasshouse", + "source:fablearium-glass-orchid", + "topic:petal-optics", + "topic:seed-trays", ], "sensitivity": "private", "allowed_surfaces": ["local", "discord"], - "created_at": "2026-07-02T04:18:00Z", - "updated_at": "2026-07-02T04:39:17Z", + "created_at": "2026-09-01T10:00:00Z", + "updated_at": "2026-09-01T10:25:00Z", "kanban_settlement": { "decision": "SAVE", - "intake_task_id": "t_status_intake", - "review_task_id": "t_status_review", - "settled_at": "2026-07-02T04:29:29Z", - "reason_label": "reason#status_reference_id", + "intake_task_id": "t_fictional_status_intake", + "review_task_id": "t_fictional_status_review", + "settled_at": "2026-09-01T10:12:00Z", + "reason_label": "reason#fictional-glass-orchid-save", }, } base.update(overrides) @@ -73,23 +69,23 @@ def _arxiv_candidate(**overrides): def _active_candidate(**overrides): """Active (status='candidate') candidate, surface-allowed everywhere.""" base = { - "id": "cand_active_status_ref", + "id": "cand_fictional_active_status_ref", "status": "candidate", "kind": "research_source_signal", "pressure": 0.91, "summary": ( - "Active pointer candidate for status reference_id test: arXiv " - "agent-collaboration governance follow-up." + "Active pointer candidate for status reference_id test: Fablearium " + "glass-orchid propagation petal optics follow-up." ), "correlation_keys": [ - "lane:agent-society", - "topic:memory", - "topic:multi-agent", + "lane:fictional-glasshouse", + "topic:petal-optics", + "topic:seed-trays", ], "sensitivity": "private", "allowed_surfaces": ["local", "discord"], - "created_at": "2026-07-04T08:00:00Z", - "updated_at": "2026-07-04T08:00:00Z", + "created_at": "2026-09-03T14:00:00Z", + "updated_at": "2026-09-03T14:00:00Z", } base.update(overrides) return base @@ -98,19 +94,19 @@ def _active_candidate(**overrides): def _dormant_thread(): """Dormant thread seeded against the active candidate above.""" return { - "id": "sth_status_ref_dormant", + "id": "sth_fictional_status_ref_dormant", "status": "dormant", "origin": "candidate", - "origin_candidate_id": "cand_active_status_ref", + "origin_candidate_id": "cand_fictional_active_status_ref", "conscious_task": { - "id": "ctask_status_ref", + "id": "ctask_fictional_status_ref", "request_type": "THINK", - "title": "Review arXiv agent collaboration governance thread", + "title": "Review Fablearium glass-orchid propagation petal optics thread", "why": "Pin exact-subject status branch (P0-2 audit)", "expected_decision": "decide", }, - "created_at": "2026-07-04T08:00:00Z", - "updated_at": "2026-07-04T08:00:00Z", + "created_at": "2026-09-03T14:00:00Z", + "updated_at": "2026-09-03T14:00:00Z", "sensitivity": "private", "allowed_surfaces": ["local", "discord"], } @@ -120,7 +116,7 @@ def _write_config(state_dir, *, surfaces=("discord", "local")): config = { "allowed_surfaces": list(surfaces), "max_sensitivity": "private", - "instance_name": "test", + "instance_name": "fictional-glasshouse", } path = Path(state_dir) / "instance.config.json" path.parent.mkdir(parents=True, exist_ok=True) @@ -128,7 +124,7 @@ def _write_config(state_dir, *, surfaces=("discord", "local")): def _seed_state(tmp_path, *, candidates=None, threads=None, surfaces=("discord", "local")): - store = SensoriumStore(instance="test", state_dir=str(tmp_path)) + store = SensoriumStore(instance="fictional-glasshouse", state_dir=str(tmp_path)) store.ensure_dirs() _write_config(tmp_path, surfaces=surfaces) for c in (candidates or []): @@ -145,25 +141,25 @@ def test_status_reference_id_pins_thread(tmp_path): """A dormant thread's reference_id must surface as exact_subject.kind='thread'.""" _seed_state( tmp_path, - candidates=[_active_candidate(), _arxiv_candidate()], + candidates=[_active_candidate(), _glass_orchid_candidate()], threads=[_dormant_thread()], ) raw = handle_sensorium_status( - instance="test", + instance="fictional-glasshouse", state_dir=str(tmp_path), - reference_id="sth_status_ref_dormant", + reference_id="sth_fictional_status_ref_dormant", ) payload = json.loads(raw) assert payload["success"] is True, payload exact = payload["data"]["exact_subject"] assert exact["kind"] == "thread" - assert exact["id"] == "sth_status_ref_dormant" + assert exact["id"] == "sth_fictional_status_ref_dormant" assert exact["status"] == "dormant" # Title must come from conscious_task.title (the conscious-layer-visible # title), so the pointer can hand the agent back the same words the # pointer context presented. - assert exact["title"] == "Review arXiv agent collaboration governance thread" + assert exact["title"] == "Review Fablearium glass-orchid propagation petal optics thread" # Thread exact_subject does not need kanban_settlement; if present it # must not be populated from a candidate record. assert exact.get("kanban_settlement") in (None, {}, "", []) @@ -177,25 +173,25 @@ def test_status_reference_id_pins_candidate(tmp_path): """An active candidate's reference_id must surface with kind='candidate' + pressure.""" _seed_state( tmp_path, - candidates=[_active_candidate(), _arxiv_candidate()], + candidates=[_active_candidate(), _glass_orchid_candidate()], threads=[_dormant_thread()], ) raw = handle_sensorium_status( - instance="test", + instance="fictional-glasshouse", state_dir=str(tmp_path), - reference_id="cand_active_status_ref", + reference_id="cand_fictional_active_status_ref", ) payload = json.loads(raw) assert payload["success"] is True, payload exact = payload["data"]["exact_subject"] assert exact["kind"] == "candidate" - assert exact["id"] == "cand_active_status_ref" + assert exact["id"] == "cand_fictional_active_status_ref" assert exact["status"] == "candidate" assert exact["pressure"] == pytest.approx(0.91) # The active candidate has no kanban settlement yet; if the field is # returned, it must be empty rather than borrowing one from the archived - # arXiv candidate in the same store. + # Fablearium candidate in the same store. settlement = exact.get("kanban_settlement") or {} assert not settlement.get("intake_task_id") assert not exact.get("not_found") @@ -210,27 +206,27 @@ def test_status_reference_id_pins_saved_residue(tmp_path): handle on a saved residue it never saw promoted.""" _seed_state( tmp_path, - candidates=[_active_candidate(), _arxiv_candidate()], + candidates=[_active_candidate(), _glass_orchid_candidate()], threads=[_dormant_thread()], ) raw = handle_sensorium_status( - instance="test", + instance="fictional-glasshouse", state_dir=str(tmp_path), - reference_id="cand_arxiv_status_ref", + reference_id="cand_fictional_glass_orchid_status_ref", ) payload = json.loads(raw) assert payload["success"] is True, payload exact = payload["data"]["exact_subject"] assert exact["kind"] == "saved_residue" - assert exact["id"] == "cand_arxiv_status_ref" + assert exact["id"] == "cand_fictional_glass_orchid_status_ref" # Saved residue is archived; kind is the active-status twin. assert exact["status"] == "archived" # kanban_settlement block is the whole point of this branch. settlement = exact["kanban_settlement"] assert settlement["decision"] == "SAVE" - assert settlement["intake_task_id"] == "t_status_intake" - assert settlement["review_task_id"] == "t_status_review" + assert settlement["intake_task_id"] == "t_fictional_status_intake" + assert settlement["review_task_id"] == "t_fictional_status_review" assert not exact.get("not_found") @@ -241,16 +237,16 @@ def test_status_reference_id_classifies_reviewed_kanban_settlement_as_saved_resi result = apply_kanban_settlement( store, decision=decision, - candidate_id="cand_active_status_ref", + candidate_id="cand_fictional_active_status_ref", intake_task_id="t_settlement_intake", review_task_id="t_settlement_review", ) assert result["action"] == "settled" raw = handle_sensorium_status( - instance="test", + instance="fictional-glasshouse", state_dir=str(tmp_path), - reference_id="cand_active_status_ref", + reference_id="cand_fictional_active_status_ref", ) payload = json.loads(raw) exact = payload["data"]["exact_subject"] @@ -268,14 +264,14 @@ def test_status_reference_id_unknown_marks_not_found(tmp_path): not_found, not silently coerced into the top-N or omitted entirely.""" _seed_state( tmp_path, - candidates=[_active_candidate(), _arxiv_candidate()], + candidates=[_active_candidate(), _glass_orchid_candidate()], threads=[_dormant_thread()], ) raw = handle_sensorium_status( - instance="test", + instance="fictional-glasshouse", state_dir=str(tmp_path), - reference_id="cand_doesnotexist", + reference_id="cand_fictional_doesnotexist", ) payload = json.loads(raw) assert payload["success"] is True, payload @@ -283,7 +279,7 @@ def test_status_reference_id_unknown_marks_not_found(tmp_path): assert exact["not_found"] is True # The id the caller asked about must be echoed back, so the conscious # layer can log the mismatch against the pointer that presented it. - assert exact.get("id") == "cand_doesnotexist" + assert exact.get("id") == "cand_fictional_doesnotexist" # An unknown id is not a thread, not a candidate, not a saved residue. assert exact.get("kind") in (None, "", "unknown") @@ -300,17 +296,17 @@ def test_status_reference_id_respects_surface(tmp_path): tmp_path, candidates=[ _active_candidate(allowed_surfaces=["local"]), # local only! - _arxiv_candidate(), + _glass_orchid_candidate(), ], threads=[], surfaces=("discord", "local"), ) raw = handle_sensorium_status( - instance="test", + instance="fictional-glasshouse", state_dir=str(tmp_path), surface="discord", - reference_id="cand_active_status_ref", + reference_id="cand_fictional_active_status_ref", ) payload = json.loads(raw) assert payload["success"] is True, payload @@ -331,12 +327,12 @@ def test_status_without_reference_id_keeps_existing_top_n(tmp_path): """ _seed_state( tmp_path, - candidates=[_active_candidate(), _arxiv_candidate()], + candidates=[_active_candidate(), _glass_orchid_candidate()], threads=[_dormant_thread()], ) # No reference_id kwarg at all. - raw = handle_sensorium_status(instance="test", state_dir=str(tmp_path)) + raw = handle_sensorium_status(instance="fictional-glasshouse", state_dir=str(tmp_path)) payload = json.loads(raw) assert payload["success"] is True, payload data = payload["data"] @@ -346,12 +342,12 @@ def test_status_without_reference_id_keeps_existing_top_n(tmp_path): assert isinstance(data["top_threads"], list) assert isinstance(data["top_candidates"], list) assert len(data["top_threads"]) == 1 - assert data["top_threads"][0]["id"] == "sth_status_ref_dormant" + assert data["top_threads"][0]["id"] == "sth_fictional_status_ref_dormant" assert data["top_threads"][0]["status"] == "dormant" # Only status=='candidate' rows appear in top_candidates; archived rows # with a kanban SAVE are NOT in top_candidates (they live in saved_residue). assert len(data["top_candidates"]) == 1 - assert data["top_candidates"][0]["id"] == "cand_active_status_ref" + assert data["top_candidates"][0]["id"] == "cand_fictional_active_status_ref" assert data["top_candidates"][0]["pressure"] == pytest.approx(0.91) # Counts remain consistent with the top-N. diff --git a/tests/test_tools.py b/tests/test_tools.py index 49b81cf..279611a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -252,6 +252,7 @@ def test_duplicate_event_import_is_idempotent(self, state_dir): def test_related_event_updates_existing_candidate(self, state_dir): first_event = { "id": "evt_rel_1", + "source_signal_ids": ["sig-fictional-event-r1"], "ts": "2026-05-25T00:00:00Z", "type": "sensor.event.promoted", "kind": "task_result", @@ -263,6 +264,7 @@ def test_related_event_updates_existing_candidate(self, state_dir): } second_event = { "id": "evt_rel_2", + "source_signal_ids": ["sig-fictional-event-r2"], "ts": "2026-05-25T00:05:00Z", "type": "sensor.event.promoted", "kind": "task_result", @@ -273,6 +275,16 @@ def test_related_event_updates_existing_candidate(self, state_dir): "allowed_surfaces": ["local"], } + store = SensoriumStore(instance="test", state_dir=state_dir) + for revision in ("r1", "r2"): + store.append_jsonl("signals", { + "id": f"sig-fictional-event-{revision}", + "sensor": "fictional.glasshouse", "source": "artifact", + "kind": "task_result", "source_revision": revision, + "summary": f"Fictional glass-orchid material {revision}", + "artifact_meta": {"source_id": "fictional-catalog", "item_id": "glass-orchid-01"}, + }) + first = json.loads(handle_sensorium_ingest_event(event=first_event, instance="test", state_dir=state_dir)) second = json.loads(handle_sensorium_ingest_event(event=second_event, instance="test", state_dir=state_dir)) From d7b944311d578e2b4537678b9959f973ea90cf07 Mon Sep 17 00:00:00 2001 From: TKCen <5960303+TKCen@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:14:07 +0200 Subject: [PATCH 02/16] Fetch full CI history for pinned baseline regression controls --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 990b846..174391e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 From 853802070e1dc066988866a38404447fb341a65f Mon Sep 17 00:00:00 2001 From: TKCen <5960303+TKCen@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:14:32 +0200 Subject: [PATCH 03/16] Preserve replay visibility and require complete revision attribution --- agent_sensorium/admission.py | 56 ++++--- agent_sensorium/admission_index.py | 37 ++-- agent_sensorium/tools.py | 92 +++++++++- docs/attention-and-workroom/architecture.md | 4 +- docs/attention-and-workroom/change-scope.md | 4 +- .../design-rationale.md | 2 +- docs/attention-and-workroom/migration.md | 2 +- tests/test_core_cache_injection.py | 13 +- tests/test_pr77_review_regressions.py | 158 ++++++++++++++++++ 9 files changed, 301 insertions(+), 67 deletions(-) create mode 100644 tests/test_pr77_review_regressions.py diff --git a/agent_sensorium/admission.py b/agent_sensorium/admission.py index 9c3cc4f..7eea24c 100644 --- a/agent_sensorium/admission.py +++ b/agent_sensorium/admission.py @@ -150,11 +150,6 @@ def _claim_for_signal(instance: str, signal: dict) -> tuple[dict | None, str | N "revision_key": _opaque("revision", revision_values_for_key), "identity_mode": "source", "evidence_class": "source_owned", - # Disposable attribution only; base grammar ignored revision aliases. - "_pre_material_admission_key": _opaque("admission", [ - _opaque("item", item_values), - _opaque("revision", [*item_values, "unversioned"]), - ]), }, None transition_fields = any( @@ -199,22 +194,10 @@ def _snapshot_claim(instance: str, signal: dict, snap: dict) -> tuple[dict | Non return _claim_for_signal(instance, signal) -def _legacy_research_admission_key(store, binding: dict, snap: dict) -> str | None: - """Attribute old unversioned disposition only to unchanged source lineage.""" - keys = [] - for signal in snap["signals"]: - claim, error = _snapshot_claim(store.instance, signal, snap) - if not error and claim and claim.get("item_key") == binding.get("item_key"): - keys.append(claim.get("_pre_material_admission_key")) - return keys[-1] if keys and isinstance(keys[-1], str) else None - - -def _same_disposition(prior: dict, binding: dict, legacy_key: str | None) -> bool: - return prior.get("admission_key") == binding["admission_key"] or ( - legacy_key is not None and prior.get("admission_key") == legacy_key - and prior.get("source_candidate_id") == binding["source_candidate_id"] - and prior.get("source_candidate_fingerprint") == binding["source_candidate_fingerprint"] - ) +def _same_disposition(prior: dict, binding: dict) -> bool: + # Representation/retained IDs cannot prove an old joined producer revision. + # Genuine unversioned settlements still match their exact admission key. + return prior.get("admission_key") == binding["admission_key"] def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dict]]) -> tuple[dict | None, str | None]: @@ -231,18 +214,42 @@ def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dic row.get("id"): row for row in snap["signals"] if isinstance(row, dict) } joined_signal_ids: set[str] = set() + seen_signal_ids: set[str] = set() + duplicate_signal_ids: set[str] = set() + for row in snap["signals"]: + identifier = row.get("id") if isinstance(row, dict) else None + if isinstance(identifier, str): + if identifier in seen_signal_ids: + duplicate_signal_ids.add(identifier) + seen_signal_ids.add(identifier) dangling = False for event_id in candidate.get("event_ids") or []: event = event_index.get(event_id) if not isinstance(event, dict): dangling = True continue - for signal_id in event.get("source_signal_ids") or []: + signal_ids = event.get("source_signal_ids") or [] + if not isinstance(signal_ids, list) or not signal_ids: + dangling = True + continue + event_claims = set() + for signal_id in signal_ids: + if not isinstance(signal_id, str) or signal_id in duplicate_signal_ids: + dangling = True + continue signal = signal_index.get(signal_id) if not isinstance(signal, dict): dangling = True continue joined_signal_ids.add(signal_id) + parsed, error = _snapshot_claim(store.instance, signal, snap) + if error: + return None, error + if parsed is not None: + event_claims.add((parsed["item_key"], parsed["revision_key"], + tuple(parsed.get("member_keys") or []))) + if len(event_claims) > 1: + dangling = True # Candidate/Event references are membership only. Select and validate joined # signals in the canonical retained receipt order supplied by read_jsonl. @@ -506,14 +513,13 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup relevant: list[dict] = [] decided = False reason = None - legacy_key = _legacy_research_admission_key(store, binding, snap) for row in snap["decisions"]: prior = row.get("admission_binding") if not isinstance(prior, dict) or prior.get("item_key") != binding["item_key"]: continue relevant.append(_decision_projection(row)) if ( - _same_disposition(prior, binding, legacy_key) + _same_disposition(prior, binding) and _is_successful_applying_disposition(row) ): decided, reason = True, "prior_disposition" @@ -530,7 +536,7 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup "status": row.get("status"), "admission_key": prior.get("admission_key"), }) - if _same_disposition(prior, binding, legacy_key): + if _same_disposition(prior, binding): decided, reason = True, "prior_disposition" elif row.get("status") in _TERMINAL_STATUSES: decided, reason = True, "explicit_item_closure" diff --git a/agent_sensorium/admission_index.py b/agent_sensorium/admission_index.py index 72f606d..83cd2ca 100644 --- a/agent_sensorium/admission_index.py +++ b/agent_sensorium/admission_index.py @@ -19,8 +19,8 @@ MAX_RECORD_BYTES = 1024 * 1024 BOUNDARY_BYTES = 64 SQLITE_TIMEOUT_SECONDS = 0.2 -# Identity grammar and raw/projection trust changed; rebuild disposable state. -SCHEMA_VERSION = 4 +# Per-event revision joins and disposition attribution changed; rebuild state. +SCHEMA_VERSION = 5 MAX_DEPENDENT_REFRESHES_PER_PASS = 64 @@ -495,27 +495,6 @@ def _prior_rows(self, conn: sqlite3.Connection, binding: dict) -> tuple[list[dic "SELECT 1 FROM disposition_evidence WHERE applied=1 AND admission_key=? LIMIT 1", (binding.get("admission_key"),), ).fetchone() - if exact is None: - # Base-era accounting is attributable only to the unchanged - # source representation; fresh r2 cannot inherit settled r1. - from .admission import _legacy_research_admission_key - - source = conn.execute( - "SELECT prompt_projection_json FROM candidate_projection " - "WHERE candidate_id=? AND generation=?", - (binding.get("source_candidate_id"), self._current_generation(conn, "candidates")), - ).fetchone() - legacy_key = None if source is None else _legacy_research_admission_key( - self.store, binding, self._joined_snapshot(conn, json.loads(source[0])), - ) - if legacy_key is not None: - exact = conn.execute( - "SELECT 1 FROM disposition_evidence WHERE applied=1 AND item_key=? " - "AND admission_key=? AND source_candidate_id=? " - "AND source_candidate_fingerprint=? LIMIT 1", - (binding.get("item_key"), legacy_key, binding.get("source_candidate_id"), - binding.get("source_candidate_fingerprint")), - ).fetchone() terminal = conn.execute( "SELECT 1 FROM disposition_evidence WHERE terminal_item_closure=1 " "AND item_key=? LIMIT 1", (binding.get("item_key"),), @@ -612,6 +591,10 @@ def _insert_projection( "SELECT projection_json FROM signal_claim WHERE signal_id=? AND generation=?", (signal_id, generation), ).fetchone() + if previous is not None: + # A duplicate canonical ID is ambiguous, not last-writer proof. + projection["_admission_claim"] = None + projection["_admission_error"] = "ambiguous_signal_id" conn.execute( "INSERT OR REPLACE INTO signal_claim VALUES (?,?,?,?)", (signal_id, generation, ordinal, _json(projection)), @@ -903,9 +886,13 @@ def count_vm_step() -> int: records += 1 offset += len(parsed) complete = offset == size - boundary = hashlib.sha256(b"").hexdigest() + boundary_unchanged = not reset and offset == cursor["offset"] + boundary = ( + cursor["boundary_sha256"] if boundary_unchanged + else hashlib.sha256(b"").hexdigest() + ) charge = min(offset, BOUNDARY_BYTES) - if fd is not None and budget >= charge: + if not boundary_unchanged and fd is not None and budget >= charge: boundary, charged = self._boundary(fd, offset) consumed += charged budget -= charged diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index dcf3983..62bf3d7 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -494,6 +494,69 @@ def _promoted_signal_existing_result(signal: dict, events: list[dict], candidate return result +@_candidate_transactional +def _narrow_replay_visibility( + store: SensoriumStore, incoming: dict, *, signal_ids=(), event_ids=(), +) -> None: + """Restrict retained lineage for future disclosure, never replay its effects. + + Delivered history is not revoked. Equal/wider metadata produces no writes; + the existing sensitivity/surface merge owners only permit narrowing. + """ + signals = store.read_jsonl("signals") + events = store.read_jsonl("events") + candidates = store.read_jsonl("candidates") + signal_ids, event_ids = set(signal_ids), set(event_ids) + + def narrow(row): + sensitivity = merge_sensitivity([ + row.get("sensitivity", "private"), incoming.get("sensitivity", "private"), + ]) + surfaces = intersect_allowed_surfaces([ + row.get("allowed_surfaces") or [], incoming.get("allowed_surfaces") or [], + ]) + if sensitivity == "local_only": + surfaces = [surface for surface in surfaces if surface == "local"] + if sensitivity == row.get("sensitivity") and surfaces == sorted(row.get("allowed_surfaces") or []): + return False + row["sensitivity"], row["allowed_surfaces"] = sensitivity, surfaces + return True + + changed_signals = False + for row in signals: + if row.get("id") in signal_ids: + changed_signals = narrow(row) or changed_signals + changed_events = False + for row in events: + if row.get("id") in event_ids or signal_ids.intersection(row.get("source_signal_ids") or []): + event_ids.add(row.get("id")) + changed_events = narrow(row) or changed_events + affected = {row.get("id") for row in candidates if event_ids.intersection(row.get("event_ids") or [])} + # Advisory descendants can retain source IDs without retaining event IDs. + while True: + descendants = {row.get("id") for row in candidates + if affected.intersection(row.get("source_candidate_ids") or [])} + if descendants.issubset(affected): + break + affected.update(descendants) + changed_candidates = False + for row in candidates: + if row.get("id") in affected: + changed_candidates = narrow(row) or changed_candidates + threads = store.read_jsonl("threads") + changed_threads = False + for row in threads: + if row.get("origin_candidate_id") in affected: + changed_threads = narrow(row) or changed_threads + # Hide reachable disclosures before changing the upstream retained record. + for name, rows, changed in [ + ("threads", threads, changed_threads), ("candidates", candidates, changed_candidates), + ("events", events, changed_events), ("signals", signals, changed_signals), + ]: + if changed: + _rewrite_jsonl(store, name, rows) + + def _coalesce_candidate_with_event( store: SensoriumStore, *, @@ -554,16 +617,21 @@ def _append_event_and_create_or_update_candidate( candidates = store.read_jsonl("candidates") existing_event = _find_existing_event(events, event) - if existing_event: - return _event_to_existing_result(existing_event, candidates) - from .admission import event_source_item_identity - incoming_identity = event_source_item_identity(store, event, signals=signals) - if incoming_identity is not None: - for prior_event in events: - if event_source_item_identity(store, prior_event, signals=signals) == incoming_identity: - return _event_to_existing_result(prior_event, candidates) + if existing_event is None: + incoming_identity = event_source_item_identity(store, event, signals=signals) + if incoming_identity is not None: + existing_event = next((row for row in events + if event_source_item_identity(store, row, signals=signals) == incoming_identity), None) + if existing_event: + retained_identity = event_source_item_identity(store, existing_event, signals=signals) + replay_ids = [existing_event["id"]] + if retained_identity is not None: + replay_ids = [row["id"] for row in events + if event_source_item_identity(store, row, signals=signals) == retained_identity] + _narrow_replay_visibility(store, event, event_ids=replay_ids) + return _event_to_existing_result(existing_event, candidates) _apply_candidate_decay(store, candidates, config=config) store.append_jsonl("events", event) @@ -773,6 +841,14 @@ def handle_sensorium_ingest_signal( None, ) if existing_signal: + from .admission import signal_source_item_identity + + retained_identity = signal_source_item_identity(instance, existing_signal) + replay_ids = [existing_signal["id"]] + if retained_identity is not None: + replay_ids = [row["id"] for row in signals + if signal_source_item_identity(instance, row) == retained_identity] + _narrow_replay_visibility(store, normalized, signal_ids=replay_ids) events = store.read_jsonl("events") candidates = store.read_jsonl("candidates") return _ok(instance, _promoted_signal_existing_result(existing_signal, events, candidates)) diff --git a/docs/attention-and-workroom/architecture.md b/docs/attention-and-workroom/architecture.md index 58faeb2..74af2c0 100644 --- a/docs/attention-and-workroom/architecture.md +++ b/docs/attention-and-workroom/architecture.md @@ -31,7 +31,7 @@ A workroom adds no retention, delivery, terminal, general filesystem, delegation ## Implemented attention changes -**Producer material identity.** Research/artifact admission uses source-owned item and material revision, not thematic correlation, wrapper prose or observation time. All referenced signals must join uniquely to the same item/revision. Conflicting revision aliases, mixed identities or incomplete joins cannot establish equality. A missing revision remains unversioned. Exact replay adds no domain records, decay, prospective observation or urgency; a changed revision of the same item can coalesce. +**Producer material identity.** Research/artifact admission uses source-owned item and material revision, not thematic correlation, wrapper prose or observation time. Each event's referenced signals must join uniquely to the same item/revision; distinct events can retain successive revisions. Conflicting revision aliases, mixed identities or incomplete joins cannot establish equality. A missing revision remains unversioned. Exact replay with equivalent effective visibility adds no domain records, decay, prospective observation or urgency. Stricter replay metadata narrows retained signal/event/candidate visibility and reachable advisory descendants/threads for future disclosure; wider replay metadata cannot restore restrictions. Local-only replay excludes nonlocal surfaces. Already-delivered history is not undone. A changed revision of the same item can coalesce. Synthetic producer fragment (not a live-tool payload): @@ -50,7 +50,7 @@ Changing only wrapper text does not create new material. A genuine `revision-b` **Index progress.** The scanner advances through multiple complete JSONL records within the aggregate source-byte budget, stopping at an incomplete tail and retaining cursor/fingerprint/lock ownership. It retains record-size, SQLite timeout and dependent-refresh bounds. Measured VM steps are not a new hard execution ceiling; output limits are not permission to delete old unresolved material. -**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 4. Earlier ready caches cannot certify the new producer identity grammar and are rebuilt from canonical JSONL records. Rebuild preserves settled legacy/unversioned material and canonical history/accounting; a genuinely changed revision remains fresh. Cache readiness is not proof that its derived semantics are current. +**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 5. Earlier ready caches cannot certify per-event revision joins or disposition attribution and are rebuilt from canonical JSONL records. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. **Optional latent context.** A direct read-only call can request held/dormant metadata: diff --git a/docs/attention-and-workroom/change-scope.md b/docs/attention-and-workroom/change-scope.md index 639566b..3ee4b3b 100644 --- a/docs/attention-and-workroom/change-scope.md +++ b/docs/attention-and-workroom/change-scope.md @@ -8,8 +8,8 @@ This candidate implements three attention changes and documents a native/workroo | Feature group | Status | Behavior, reason and boundary | |---|---|---| -| Producer item/revision | **Implemented; source-reviewed** | Complete unique source joins establish material equality. Missing revision stays unversioned; conflicting aliases/mixed joins refuse equality. Raw producer payloads cannot impersonate derived cached claims. Exact replay is domain-write/observer-free; changed same-item material can coalesce. No thematic equality or manufactured old revision. | -| Bounded index cursor | **Implemented; source-reviewed** | Multiple complete records advance within aggregate source-byte budget, retaining cursor/fingerprint/lock/record-size/SQLite timeout contracts; partial tail waits. Cache generation 4 rebuilds obsolete derived identities from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | +| Producer item/revision | **Implemented; repair review pending** | Complete unique per-event source joins establish material equality. Missing revision stays unversioned; conflicting aliases/mixed joins refuse equality. Raw producer payloads cannot impersonate derived cached claims. Equivalent-visibility replay is domain-write/observer-free; stricter replay narrows future lineage visibility without pressure/observer effects. Changed same-item material can coalesce. No thematic equality or manufactured old revision. | +| Bounded index cursor | **Implemented; repair review pending** | Multiple complete records advance within aggregate source-byte budget, retaining validated boundaries when an unchanged offset exhausts the budget; partial tail waits. Cache generation 5 rebuilds obsolete join/disposition semantics from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | | Latent thread awareness | **Implemented; optional; source-reviewed** | Read-only held/dormant metadata, default off, closed visibility/duplicate rejection and honest provenance/unknowns. Six threads/two receipts/6,144-byte output; unbounded input scans, incomplete semantics. No automatic native consumer/resume. | | Native runtime contract | **Proposed; host capability held** | Immutable external approval for selection/generation/interpreter/ABI/import/distribution/route origins; drift HOLD, no automatic rebinding or fallback. Actual host verification not supplied. | | Native session continuity | **Proposed; native capability held** | Stable native root and complete owner/compression/model-history recovery before transcript access. No title adoption, silent fork, wake-summary replacement or reseed. Exact ID/DB/history adapter unproven. | diff --git a/docs/attention-and-workroom/design-rationale.md b/docs/attention-and-workroom/design-rationale.md index 0d6088d..56b5a2d 100644 --- a/docs/attention-and-workroom/design-rationale.md +++ b/docs/attention-and-workroom/design-rationale.md @@ -4,7 +4,7 @@ The [architecture](architecture.md) separates implemented attention changes from ## Equality comes from the producer -Correlation is a thematic relationship, not equality. Complete source-owned item/revision joins prevent a paraphrase, changed wrapper or fresh timestamp from reawakening the same material. Conflicting and partial joins refuse equality; missing revision stays unversioned. New material can coalesce within the same item without merging unrelated items. Exact replay must not decay pressure, append domain records or emit prospective observations. +Correlation is a thematic relationship, not equality. Complete source-owned item/revision joins prevent a paraphrase, changed wrapper or fresh timestamp from reawakening the same material. Conflicting and partial joins refuse equality; missing revision stays unversioned. New material can coalesce within the same item without merging unrelated items. Exact replay with equivalent effective visibility must not decay pressure, append domain records or emit prospective observations. Stricter replay visibility narrows future disclosure without replaying those effects; wider metadata cannot undo a restriction. Multiple complete records per bounded index pass improve progress without replacing cursor/fingerprint/lock authority. A partial tail waits for completion. Neither newest-N snapshots nor time expiry preserve old unresolved material. diff --git a/docs/attention-and-workroom/migration.md b/docs/attention-and-workroom/migration.md index c69cc14..99386f4 100644 --- a/docs/attention-and-workroom/migration.md +++ b/docs/attention-and-workroom/migration.md @@ -6,7 +6,7 @@ Attention can be updated without activating a workroom. No real accounting migra Keep canonical instance/store identities and signal/event/candidate/thread/decision/artifact/outbox ownership. Do not transplant alternate whole trees, deployment fixtures or obsolete assertions over public-only repairs. Producer revisions are not manufactured for old rows. Index progress retains cursors, fingerprints, stream boundaries and old unresolved material; no reset, newest-N deletion or disguised lifetime rescan. -The attention cache is disposable, not canonical accounting. Generation 4 invalidates older ready projections because producer identity semantics changed. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Settled base-era unversioned material stays settled; a changed revision of the same item is not hidden by an old cached claim. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. +The attention cache is disposable, not canonical accounting. Generation 5 invalidates older ready projections because per-event revision validation and disposition attribution changed. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. Exact consumer/aperture generation/lease/presentation ownership remains required. Lease expiry releases execution, not semantic settlement. Future HELD checkpoints and STOP persist. Keep session-local pointers, closed privacy policy, dashboard/URL validation and honest unknown status. Optional implemented latent projection remains read-only, default-off and honestly incomplete; output caps do not bound input I/O. diff --git a/tests/test_core_cache_injection.py b/tests/test_core_cache_injection.py index dee4af5..01484c5 100644 --- a/tests/test_core_cache_injection.py +++ b/tests/test_core_cache_injection.py @@ -100,7 +100,7 @@ def test_raw_event_and_snapshot_identity_ignore_reserved_fields(tmp_path, produc assert indexed_error is None and indexed == binding @pytest.mark.parametrize("variant", ["r1", "missing", "malformed", "conflicting", "alias", "sha256", "matching_all"]) -@pytest.mark.parametrize("old_generation", [0, 3]) +@pytest.mark.parametrize("old_generation", [0, 3, 4]) def test_public_base_schema3_cache_rebuild_matches_canonical(tmp_path, monkeypatch, variant, old_generation): store = store_at(tmp_path) created = ingest(store, signal()) @@ -172,10 +172,17 @@ def test_settled_base_unversioned_stays_settled_but_r2_is_fresh(tmp_path, monkey assert admission.build_admission_plan(store)["selection"] is None before = canonical(store) assert store.prepare_admission_index().complete - assert admission.build_admission_plan(store)["selection"] is None + if revision is None: + assert admission.build_admission_plan(store)["selection"] is None + else: + # Schema-3 recorded an unversioned key, not historical r1 proof. + assert admission.build_admission_plan(store)["selection"]["revision_key"] != old["revision_key"] assert canonical(store) == before AdmissionIndex(store).path.unlink() - assert admission.build_admission_plan(store)["selection"] is None + if revision is None: + assert admission.build_admission_plan(store)["selection"] is None + else: + assert admission.build_admission_plan(store)["selection"]["revision_key"] != old["revision_key"] assert ingest(store, signal("same-revision", revision))["duplicate"] assert canonical(store) == before updated = ingest(store, signal("fresh-r2", "r2")) diff --git a/tests/test_pr77_review_regressions.py b/tests/test_pr77_review_regressions.py new file mode 100644 index 0000000..7042a39 --- /dev/null +++ b/tests/test_pr77_review_regressions.py @@ -0,0 +1,158 @@ +"""Fictional counterexamples for boundary, settlement, replay and join owners.""" +import json +import sqlite3 + +import pytest + +from agent_sensorium import admission, tools +from agent_sensorium.admission_index import AdmissionIndex +from agent_sensorium.config import visible_on_surface +from agent_sensorium.dispatcher import candidate_to_thread +from agent_sensorium.subconscious import run_subconscious_advisory +from tests.test_core_cache_injection import canonical, public_base, raw_snapshot +from tests.test_core_material_replay import ingest, signal, store_at + + +@pytest.mark.parametrize("budget", [63, 64, 65, 128]) +def test_unchanged_boundary_budget_preserves_dependent_state(tmp_path, budget): + store = store_at(tmp_path) + created = ingest(store, signal()) + assert store.prepare_admission_index().complete + index = AdmissionIndex(store) + with sqlite3.connect(index.path) as conn: + before = conn.execute("SELECT * FROM source_cursor ORDER BY stream").fetchall() + state = conn.execute("SELECT * FROM candidate_state").fetchall() + for _ in range(3): + result = store.prepare_admission_index(scan_bytes=budget) + assert result.bytes_consumed <= budget + with sqlite3.connect(index.path) as conn: + assert conn.execute("SELECT * FROM source_cursor ORDER BY stream").fetchall() == before + assert conn.execute("SELECT * FROM candidate_state").fetchall() == state + assert admission.binding_for_candidate(store, created["candidate_id"])[0] + rows = store.read_jsonl("signals") + rows[0]["source_revision"] = "r2" + store.rewrite_jsonl("signals", rows) + assert store.prepare_admission_index().complete + assert admission.binding_for_candidate(store, created["candidate_id"])[0]["revision_key"] == admission._claim_for_signal(store.instance, rows[0])[0]["revision_key"] + + +@pytest.mark.parametrize("indexed", [False, True]) +@pytest.mark.parametrize("revision", [None, "r1", "r2"]) +@pytest.mark.parametrize("settle", ["DROP", "SAVE"]) +def test_retained_id_legacy_settlement_requires_revision_proof(tmp_path, monkeypatch, indexed, revision, settle): + store = store_at(tmp_path) + created = ingest(store, signal()) + base = public_base() + snap = raw_snapshot(store) + old, error = base._base_binding_from_snapshot(store, snap["candidates"][0], snap) + assert error is None + with monkeypatch.context() as patch: + patch.setattr(admission, "_claim_for_signal", base._claim_for_signal) + patch.setattr(admission, "_base_binding_from_snapshot", base._base_binding_from_snapshot) + result = run_subconscious_advisory(store, advisory_output={ + "action": settle, "rationale": "Fictional legacy disposition", "event_ids": [], + "candidate_ids": [created["candidate_id"]]}, admission_binding=old, + enabled=True, dry_run=False) + assert result["action"] == settle.lower() + rows = store.read_jsonl("signals") + if revision is None: + rows[0].pop("source_revision") + else: + rows[0]["source_revision"] = revision + store.rewrite_jsonl("signals", rows) + before = canonical(store) + if indexed: + assert store.prepare_admission_index().complete + selection = admission.build_admission_plan(store)["selection"] + if revision is None: + assert selection is None + else: + # A schema-3 unversioned key never proved which explicit revision settled. + assert selection and selection["revision_key"] != old["revision_key"] + assert canonical(store) == before + + +@pytest.mark.parametrize("indexed", [False, True]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("shape", ["mixed", "same", "separate", "dangling", "unknown", "duplicate"]) +def test_binding_validates_each_complete_event_revision_join(tmp_path, indexed, reverse, shape): + store = store_at(tmp_path) + rows = [signal("sig-a"), signal("sig-b", "r1" if shape == "same" else "r2")] + refs = [row["id"] for row in rows] + if shape == "dangling": + refs.append("absent") + if shape == "unknown": + rows[1].pop("artifact_meta") + if shape == "duplicate": + rows[1]["id"] = rows[0]["id"] + refs = [rows[0]["id"]] + if reverse: + rows.reverse() + refs.reverse() + events = [{"id": "evt-a", "source_signal_ids": refs}] + if shape == "separate": + events = [{"id": f"evt-{i}", "source_signal_ids": [row["id"]]} for i, row in enumerate(rows)] + candidate = {"id": "cand-fiction", "status": "candidate", "kind": "creative_pull", + "event_ids": [row["id"] for row in events]} + for stream, values in {"signals": rows, "events": events, "candidates": [candidate]}.items(): + for row in values: + store.append_jsonl(stream, row) + if indexed: + assert store.prepare_admission_index().complete + binding, error = admission.binding_for_candidate(store, candidate["id"]) + if shape in {"same", "separate"}: + assert error is None and binding["identity_mode"] == "source" + assert binding["revision_key"] == admission._claim_for_signal(store.instance, rows[-1])[0]["revision_key"] + else: + assert binding is None or binding["identity_mode"] != "source" + + +@pytest.mark.parametrize("path", ["signal_id", "signal_fingerprint", "signal_material", "event_id", "event_fingerprint", "event_material"]) +@pytest.mark.parametrize("restriction", ["sensitivity", "surface"]) +def test_replay_narrows_future_candidate_derived_and_thread_visibility(tmp_path, monkeypatch, path, restriction): + store = store_at(tmp_path) + first = signal() + first.update(sensitivity="public_safe", allowed_surfaces=["local", "telegram"]) + created = ingest(store, first) + candidate = store.read_jsonl("candidates")[0] + assert candidate["id"] == created["candidate_id"] + derived = {**candidate, "id": "cand-derived", "event_ids": [], + "source_candidate_ids": [candidate["id"]], "kind": "subconscious_advisory"} + store.append_jsonl("candidates", derived) + for source in [candidate, derived]: + store.append_jsonl("threads", candidate_to_thread(source)) + # Delivered history is not undone by a future-disclosure restriction. + store.append_jsonl("outbox", {"id": "ob-history", "status": "delivered", "source_candidate_ids": [candidate["id"]]}) + store.append_jsonl("decisions", {"type": "synthetic.control", "candidate_id": candidate["id"]}) + policy = {"allowed_surfaces": ["local", "telegram"], "max_sensitivity": "public_safe"} + assert all(visible_on_surface(row, "telegram", policy) for row in store.read_jsonl("candidates")) + before = canonical(store) + def forbidden(*args, **kwargs): + raise AssertionError("Replay must not add pressure or observations") + monkeypatch.setattr(tools, "_apply_candidate_decay", forbidden) + monkeypatch.setattr(tools, "observe_after_success", forbidden) + incoming = dict(first if path.startswith("signal") else store.read_jsonl("events")[0]) + if not path.endswith("_id"): + incoming["id"] = "sig-wrapper" if path.startswith("signal") else "evt-wrapper" + if path.endswith("_material"): + incoming["summary"] = "Fictional wrapper variation" + if restriction == "sensitivity": + incoming["sensitivity"] = "local_only" + else: + incoming["allowed_surfaces"] = ["local"] + handler = tools.handle_sensorium_ingest_signal if path.startswith("signal") else tools.handle_sensorium_ingest_event + keyword = "signal" if path.startswith("signal") else "event" + result = json.loads(handler(**{keyword: incoming}, instance=store.instance, state_dir=str(store.root), config={})) + assert result["success"] and result["data"]["duplicate"] + for name in ["candidates", "threads"]: + assert all(not visible_on_surface(row, "telegram", policy) for row in store.read_jsonl(name)) + after = canonical(store) + for name in ["decisions", "outbox"]: + assert after[name] == before[name] + for retained in store.read_jsonl("candidates"): + assert retained["pressure"] == candidate["pressure"] + assert retained["event_ids"] == (candidate["event_ids"] if retained["id"] == candidate["id"] else []) + # Equal and wider metadata cannot restore any restriction or cause writes. + for replay in [incoming, {**incoming, "sensitivity": "public_safe", "allowed_surfaces": ["telegram", "local"]}]: + assert json.loads(handler(**{keyword: replay}, instance=store.instance, state_dir=str(store.root), config={}))["data"]["duplicate"] + assert canonical(store) == after From cfc6a9b768ffab0a2a1552029a63c2e69657d0bf Mon Sep 17 00:00:00 2001 From: TKCen <5960303+TKCen@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:53:29 +0200 Subject: [PATCH 04/16] fix: honor surface visibility in ordinary status projections --- agent_sensorium/tools.py | 56 +++++-- tests/test_status_surface_visibility.py | 187 ++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 tests/test_status_surface_visibility.py diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index 62bf3d7..a8ad4ab 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -143,6 +143,28 @@ def _legacy_state_latest_info(store: SensoriumStore, state: dict) -> dict: "excluded_from_freshness": True, } +def _status_record_visible( + record: dict, *, candidates: list[dict], threads: list[dict], + surface: str, instance_config: dict, +) -> bool: + """Gate linked status prose against current subjects, not stale snapshots. + + Unlinked operational diagnostics remain global. A linked subject must still + exist and be allowed; its status need not be active for a historical receipt. + """ + from .config import visible_on_surface + + for key, rows in (("candidate_id", candidates), ("origin_candidate_id", candidates), + ("thread_id", threads)): + subject_id = record.get(key) + if not subject_id: + continue + subject = next((row for row in rows if row.get("id") == subject_id), None) + if subject is None or not visible_on_surface(subject, surface, instance_config): + return False + return True + + def handle_sensorium_status( *, instance: str = "default", state_dir: str | None = None, config_path: str | None = None, @@ -155,12 +177,11 @@ def handle_sensorium_status( the response includes an ``exact_subject`` block naming that specific thread or candidate, rather than forcing the caller to guess which row of the rotating top-N corresponds to the pointer the agent just saw. - ``surface`` is honored for the exact-subject branch so a local-only - candidate is reported as not_found when requested from discord. When - ``reference_id`` is omitted, the legacy top-N shape is preserved - byte-for-byte for backwards compatibility. + ``surface`` (default local) gates top lists, exact subjects and linked + historical prose using current visibility. Filtering precedes top-N caps; + list shapes and ranking remain compatible. Coarse counts stay global. """ - from .config import load_instance_config + from .config import load_instance_config, visible_on_surface store = SensoriumStore(instance=instance, state_dir=state_dir) store.ensure_dirs() @@ -176,10 +197,14 @@ def handle_sensorium_status( artifacts = store.read_jsonl("artifacts") state = store.read_state() + status_surface = surface or "local" active_candidates = [c for c in candidates if c.get("status") == "candidate"] - active_candidates.sort(key=lambda c: c.get("pressure", 0), reverse=True) + visible_candidates = [c for c in active_candidates + if visible_on_surface(c, status_surface, instance_config)] + visible_candidates.sort(key=lambda c: c.get("pressure", 0), reverse=True) - visible_threads = [t for t in threads if t.get("status") in ("dormant", "held")] + visible_threads = [t for t in threads if t.get("status") in ("dormant", "held") + and visible_on_surface(t, status_surface, instance_config)] visible_threads.sort(key=lambda t: t.get("created_at", ""), reverse=True) active_threads = [t for t in threads if t.get("status") in ("dormant", "held")] @@ -233,7 +258,7 @@ def handle_sensorium_status( "pressure": c.get("pressure"), "summary": truncate_text(c.get("summary", ""), 120), } - for c in active_candidates[:5] + for c in visible_candidates[:5] ], "top_threads": [ { @@ -250,7 +275,7 @@ def handle_sensorium_status( "legacy_state_latest": _legacy_state_latest_info(store, state), "volunteer_cards": build_volunteer_cards( store, - surface=surface or "local", + surface=status_surface, instance_config=instance_config, config_path=config_path, limit=3, @@ -259,7 +284,10 @@ def handle_sensorium_status( } decisions = store.read_jsonl("decisions") - if decisions: + if decisions and _status_record_visible( + decisions[-1], candidates=candidates, threads=threads, + surface=status_surface, instance_config=instance_config, + ): latest = decisions[-1] data["latest_decision"] = { "ts": latest.get("ts"), @@ -273,7 +301,10 @@ def handle_sensorium_status( if state: if "state_version" in state: data["state_version"] = state.get("state_version") - if "last_dispatch_result" in state: + if "last_dispatch_result" in state and _status_record_visible( + state.get("last_dispatch_result") or {}, candidates=candidates, threads=threads, + surface=status_surface, instance_config=instance_config, + ): legacy_result = dict(state.get("last_dispatch_result") or {}) raw_legacy_action = legacy_result.get("action") if raw_legacy_action in {"would_promote", "promoted"}: @@ -294,10 +325,9 @@ def handle_sensorium_status( # visibility. This lets the conscious layer recover "the subject the # prior pointer presented" without guessing across a rotating top-N. if reference_id: - exact_surface = surface or "local" data["exact_subject"] = _resolve_exact_subject( reference_id=reference_id, - surface=exact_surface, + surface=status_surface, threads=threads, candidates=candidates, instance_config=instance_config, diff --git a/tests/test_status_surface_visibility.py b/tests/test_status_surface_visibility.py new file mode 100644 index 0000000..560fc21 --- /dev/null +++ b/tests/test_status_surface_visibility.py @@ -0,0 +1,187 @@ +"""Synthetic ordinary status visibility and read-only projection controls.""" +import json +import pytest +from agent_sensorium import tools, config +from agent_sensorium.dispatcher import candidate_to_thread +from tests.test_core_material_replay import signal, store_at, ingest + +@pytest.mark.parametrize('path', ['signal_id','signal_fingerprint','signal_material','event_id','event_fingerprint','event_material']) +@pytest.mark.parametrize('restriction', ['sensitivity','surface']) +def test_ordinary_forbidden_surface_status_has_no_restricted_prose(tmp_path, monkeypatch, path, restriction): + store=store_at(tmp_path) + first=signal() + first.update(summary='Fictional withheld material marker',sensitivity='public_safe',allowed_surfaces=['local','telegram']) + ingest(store,first) + candidate=store.read_jsonl('candidates')[0] + derived={**candidate,'id':'cand-probe-descendant','event_ids':[], 'source_candidate_ids':[candidate['id']], 'kind':'subconscious_advisory'} + store.append_jsonl('candidates',derived) + for row in [candidate,derived]: + store.append_jsonl('threads',candidate_to_thread(row)) + policy={'allowed_surfaces':['local','telegram'],'max_sensitivity':'public_safe'} + monkeypatch.setattr(config,'load_instance_config',lambda **kwargs:(policy,{})) + kw={'instance':store.instance,'state_dir':str(store.root),'surface':'telegram'} + assert json.loads(tools.handle_sensorium_candidate_open(candidate_id=candidate['id'],**kw))['success'] + for row in store.read_jsonl('threads'): + assert json.loads(tools.handle_sensorium_thread_open(thread_id=row['id'],**kw))['success'] + before=json.loads(tools.handle_sensorium_status(reference_id=candidate['id'],**kw)) + assert not before['data']['exact_subject'].get('not_found') + incoming=dict(first if path.startswith('signal') else store.read_jsonl('events')[0]) + if not path.endswith('_id'): + incoming['id']='probe-replay-wrapper' + if path.endswith('_material'): + incoming['summary']='Fictional alternate wrapper' + if restriction=='sensitivity': + incoming['sensitivity']='local_only' + else: + incoming['allowed_surfaces']=['local'] + handler=tools.handle_sensorium_ingest_signal if path.startswith('signal') else tools.handle_sensorium_ingest_event + keyword='signal' if path.startswith('signal') else 'event' + result=json.loads(handler(**{keyword:incoming},instance=store.instance,state_dir=str(store.root),config={})) + assert result['success'] and result['data']['duplicate'] + opens=[] + for row in store.read_jsonl('candidates'): + opened=json.loads(tools.handle_sensorium_candidate_open(candidate_id=row['id'],**kw)) + assert not opened['success'] and opened['data'] is None + opens.append({'kind':'candidate','denied':True}) + for row in store.read_jsonl('threads'): + opened=json.loads(tools.handle_sensorium_thread_open(thread_id=row['id'],**kw)) + assert not opened['success'] and opened['data'] is None + opens.append({'kind':'thread','denied':True}) + before_state = _snapshot(store) + output=json.loads(tools.handle_sensorium_status(reference_id=candidate['id'],**kw)) + assert _snapshot(store) == before_state + assert output['success'] and output['data']['exact_subject']['not_found'] + candidates=output['data']['top_candidates'] + threads=output['data']['top_threads'] + assert not candidates and not threads, 'Restricted rows must not enter status top lists' + assert 'Fictional withheld material marker' not in json.dumps(output) + for reference in ['', *[r['id'] for r in store.read_jsonl('threads')]]: + status = json.loads(tools.handle_sensorium_status(reference_id=reference, **kw)) + assert 'Fictional withheld material marker' not in json.dumps(status) + if reference: + assert status['data']['exact_subject']['not_found'] + policy['max_sensitivity'] = 'local_only' if restriction == 'sensitivity' else 'public_safe' + local_kw = {**kw, 'surface': 'local'} + before_state = _snapshot(store) + for reference in ['', candidate['id'], *[r['id'] for r in store.read_jsonl('threads')]]: + local = json.loads(tools.handle_sensorium_status(reference_id=reference, **local_kw)) + assert len(local['data']['top_candidates']) == 2 + assert len(local['data']['top_threads']) == 2 + assert 'Fictional withheld material marker' in json.dumps(local) + if reference: + assert not local['data']['exact_subject'].get('not_found') + assert _snapshot(store) == before_state + + +def _snapshot(store): + return {str(path.relative_to(store.root)): path.read_bytes() + for path in store.root.rglob('*') if path.is_file()} + + +@pytest.mark.parametrize('restriction', ['sensitivity', 'surface']) +@pytest.mark.parametrize('sibling', ['latest_decision', 'legacy_dispatch_result']) +def test_linked_status_prose_uses_current_subject_visibility(tmp_path, monkeypatch, restriction, sibling): + store = store_at(tmp_path) + first = signal() + marker = 'Fictional withheld sibling marker' + first.update(summary=marker, sensitivity='public_safe', allowed_surfaces=['local', 'telegram']) + ingest(store, first) + candidate = store.read_jsonl('candidates')[0] + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'public_safe'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + if sibling == 'latest_decision': + thread = candidate_to_thread(candidate) + store.append_jsonl('threads', thread) + updated = json.loads(tools.handle_sensorium_thread_update( + thread_id=thread['id'], action='hold', reason=marker, + instance=store.instance, state_dir=str(store.root))) + assert updated['success'] + else: + dispatched = json.loads(tools.handle_sensorium_dispatch_once( + instance=store.instance, state_dir=str(store.root), dry_run=True)) + assert dispatched['success'] + assert marker in json.dumps(dispatched) + kw = {'instance': store.instance, 'state_dir': str(store.root), 'surface': 'telegram'} + assert marker in json.dumps(json.loads(tools.handle_sensorium_status(**kw))['data'][sibling]) + incoming = dict(first) + if restriction == 'sensitivity': + incoming['sensitivity'] = 'local_only' + else: + incoming['allowed_surfaces'] = ['local'] + assert ingest(store, incoming)['duplicate'] + before = _snapshot(store) + restricted = json.loads(tools.handle_sensorium_status(**kw)) + assert marker not in json.dumps(restricted) + assert _snapshot(store) == before + policy['max_sensitivity'] = 'local_only' if restriction == 'sensitivity' else 'public_safe' + local = json.loads(tools.handle_sensorium_status(**{**kw, 'surface': 'local'})) + assert marker in json.dumps(local['data'][sibling]) + assert _snapshot(store) == before + + +@pytest.mark.parametrize('surface', ['', 'local', 'telegram']) +def test_top_n_filters_before_cap_preserves_ranking_and_global_counts(tmp_path, monkeypatch, surface): + store = store_at(tmp_path) + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'public_safe'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + for index in range(12): + hidden = index < 6 + candidate = {'id': f'cand-synthetic-{index}', 'status': 'candidate', + 'summary': 'Fictional hidden high rank' if hidden else f'Visible {index}', + 'pressure': 1 - index / 100, 'kind': 'creative_pull', + 'allowed_surfaces': ['discord'] if hidden else ['local', 'telegram'], + 'sensitivity': 'public_safe'} + store.append_jsonl('candidates', candidate) + thread = candidate_to_thread(candidate) + thread.update(id=f'thread-synthetic-{index}', created_at=f'2026-01-{20-index:02d}T00:00:00Z') + store.append_jsonl('threads', thread) + before = _snapshot(store) + data = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface=surface))['data'] + assert [row['id'] for row in data['top_candidates']] == [f'cand-synthetic-{i}' for i in range(6, 11)] + assert [row['id'] for row in data['top_threads']] == [f'thread-synthetic-{i}' for i in range(6, 11)] + assert data['counts']['active_candidates'] == 12 + assert data['counts']['dormant_threads'] == 12 + assert 'Fictional hidden high rank' not in json.dumps(data) + assert _snapshot(store) == before + + +@pytest.mark.parametrize('path', ['signal_id', 'signal_fingerprint', 'signal_material', + 'event_id', 'event_fingerprint', 'event_material']) +@pytest.mark.parametrize('wider', [False, True]) +def test_equal_or_wider_replay_and_status_preserve_canonical_state(tmp_path, monkeypatch, path, wider): + store = store_at(tmp_path) + first = signal() + ingest(store, first) + candidate = store.read_jsonl('candidates')[0] + thread = candidate_to_thread(candidate) + store.append_jsonl('threads', thread) + store.append_jsonl('decisions', {'type': 'thread.updated', 'thread_id': thread['id'], + 'reason': 'Fictional preserved reason'}) + store.append_jsonl('artifacts', {'id': 'artifact-synthetic', 'thread_id': thread['id']}) + store.write_state({'budgets': {}, 'delivered_history': ['Fictional delivered history']}) + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'private'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + incoming = dict(first if path.startswith('signal') else store.read_jsonl('events')[0]) + if not path.endswith('_id'): + incoming['id'] = 'synthetic-replay-wrapper' + if path.endswith('_material'): + incoming['summary'] = 'Fictional alternate wrapper' + if wider: + incoming.update(sensitivity='public_safe', allowed_surfaces=['local', 'telegram']) + before = _snapshot(store) + handler = tools.handle_sensorium_ingest_signal if path.startswith('signal') else tools.handle_sensorium_ingest_event + keyword = 'signal' if path.startswith('signal') else 'event' + replay = json.loads(handler(**{keyword: incoming}, instance=store.instance, + state_dir=str(store.root), config={})) + assert replay['success'] and replay['data']['duplicate'] + assert _snapshot(store) == before + for reference in ['', candidate['id'], thread['id']]: + local = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface='local', reference_id=reference)) + assert len(local['data']['top_candidates']) == 1 + assert len(local['data']['top_threads']) == 1 + assert local['data']['latest_decision']['reason'] == 'Fictional preserved reason' + if reference: + assert not local['data']['exact_subject'].get('not_found') + assert _snapshot(store) == before From 3f839956f27c785516d2117012f169c9dfe9dcb9 Mon Sep 17 00:00:00 2001 From: TKCen <5960303+TKCen@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:12:07 +0200 Subject: [PATCH 05/16] Close status visibility for source-linked advisory receipts --- agent_sensorium/tools.py | 36 +++- tests/test_status_surface_visibility.py | 235 ++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 6 deletions(-) diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index a8ad4ab..e50bbe6 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -149,19 +149,43 @@ def _status_record_visible( ) -> bool: """Gate linked status prose against current subjects, not stale snapshots. - Unlinked operational diagnostics remain global. A linked subject must still - exist and be allowed; its status need not be active for a historical receipt. + All declared direct and admission source subjects must exist and be allowed, + even for historical receipts. Resolve visibility only, never live admission + or index readiness. Unresolved advisory choices carry source prose, not + global diagnostics; genuinely unlinked operational diagnostics remain global. """ from .config import visible_on_surface - for key, rows in (("candidate_id", candidates), ("origin_candidate_id", candidates), - ("thread_id", threads)): - subject_id = record.get(key) - if not subject_id: + subjects = [(record.get(key), rows) for key, rows in ( + ("candidate_id", candidates), ("origin_candidate_id", candidates), + ("thread_id", threads), + )] + binding = record.get("admission_binding") + if binding is not None: + if not isinstance(binding, dict): + return False + source_id = binding.get("source_candidate_id") + if not isinstance(source_id, str) or not source_id.strip(): + return False + subjects.append((source_id, candidates)) + + linked = False + for subject_id, rows in subjects: + if subject_id is None: continue + if not isinstance(subject_id, str) or not subject_id.strip(): + return False + linked = True subject = next((row for row in rows if row.get("id") == subject_id), None) if subject is None or not visible_on_surface(subject, surface, instance_config): return False + if not linked and record.get("type") == "subconscious.advisory": + if (record.get("output_action") in ("DROP", "SAVE", "CREATE_CONSCIOUS_TASK") + or record.get("action") in ( + "drop", "save", "would_create_conscious_task", + "created_conscious_task_candidate", "updated_conscious_task_candidate", + )): + return False return True diff --git a/tests/test_status_surface_visibility.py b/tests/test_status_surface_visibility.py index 560fc21..37f7671 100644 --- a/tests/test_status_surface_visibility.py +++ b/tests/test_status_surface_visibility.py @@ -185,3 +185,238 @@ def test_equal_or_wider_replay_and_status_preserve_canonical_state(tmp_path, mon if reference: assert not local['data']['exact_subject'].get('not_found') assert _snapshot(store) == before + + +@pytest.mark.parametrize('action', ['DROP', 'SAVE', 'CREATE_CONSCIOUS_TASK']) +@pytest.mark.parametrize('dry_run', [True, False]) +@pytest.mark.parametrize('restriction', ['surface', 'sensitivity']) +def test_bound_advisory_reason_obeys_current_source(tmp_path, monkeypatch, action, dry_run, restriction): + store = store_at(tmp_path) + marker = 'Fictional source-bound advisory withheld marker' + first = signal() + first.update(summary=marker, sensitivity='public_safe', allowed_surfaces=['local', 'telegram']) + ingest(store, first) + candidate = store.read_jsonl('candidates')[0] + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'public_safe'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + output = {'action': action, 'rationale': marker, 'candidate_ids': [candidate['id']], + 'event_ids': candidate['event_ids']} + if action == 'CREATE_CONSCIOUS_TASK': + output['conscious_task'] = {'request_type': 'THINK', 'title': marker, 'why': marker, + 'expected_decision': 'Hold or engage'} + produced = json.loads(tools.handle_sensorium_subconscious_advisory( + instance=store.instance, state_dir=str(store.root), enabled=True, dry_run=dry_run, + advisory_output=output, config={'model_enabled': False}, record_receipt=True)) + assert produced['success'], produced + receipt = store.read_jsonl('decisions')[-1] + assert receipt['type'] == 'subconscious.advisory' + assert receipt['admission_binding']['source_candidate_id'] == candidate['id'] + assert (receipt['candidate_id'] is None) == (dry_run or action != 'CREATE_CONSCIOUS_TASK') + kw = {'instance': store.instance, 'state_dir': str(store.root), 'surface': 'telegram'} + assert marker in json.dumps(json.loads(tools.handle_sensorium_status(**kw))) + incoming = dict(first) + if restriction == 'surface': + incoming['allowed_surfaces'] = ['local'] + else: + incoming['sensitivity'] = 'local_only' + assert ingest(store, incoming)['duplicate'] + before = _snapshot(store) + forbidden = json.loads(tools.handle_sensorium_status(reference_id=candidate['id'], **kw)) + assert forbidden['data']['exact_subject']['not_found'] + assert forbidden['data']['top_candidates'] == [] + assert not json.loads(tools.handle_sensorium_candidate_open(candidate_id=candidate['id'], **kw))['success'] + policy['max_sensitivity'] = 'local_only' if restriction == 'sensitivity' else 'public_safe' + local = json.loads(tools.handle_sensorium_status(**{**kw, 'surface': 'local'})) + assert marker in json.dumps(local) + assert _snapshot(store) == before + assert marker not in json.dumps(forbidden) + + +@pytest.mark.parametrize('action', ['DROP', 'SAVE']) +@pytest.mark.parametrize('dry_run', [True, False]) +@pytest.mark.parametrize('restriction', ['surface', 'sensitivity']) +def test_unbound_multi_source_reason_is_not_global(tmp_path, monkeypatch, action, dry_run, restriction): + store = store_at(tmp_path) + marker = 'Fictional multi-source rationale marker' + sources = [] + for item in ['a', 'b']: + row = signal(identifier=f'sig-{item}', item=item) + row.update(summary=marker, sensitivity='public_safe', allowed_surfaces=['local', 'telegram']) + ingest(store, row) + sources.append(row) + candidates = store.read_jsonl('candidates') + assert len(candidates) == 2 + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'public_safe'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + response = json.loads(tools.handle_sensorium_subconscious_advisory( + instance=store.instance, state_dir=str(store.root), enabled=True, dry_run=dry_run, + advisory_output={'action': action, 'rationale': marker, + 'candidate_ids': [c['id'] for c in candidates], + 'event_ids': [e for c in candidates for e in c['event_ids']]}, + config={'model_enabled': False}, record_receipt=True)) + assert response['success'], response + decision = store.read_jsonl('decisions')[-1] + assert decision['type'] == 'subconscious.advisory' + assert decision['admission_binding'] is None and decision['candidate_id'] is None + for row in sources: + incoming = dict(row) + if restriction == 'surface': + incoming['allowed_surfaces'] = ['local'] + else: + incoming['sensitivity'] = 'local_only' + assert ingest(store, incoming)['duplicate'] + before = _snapshot(store) + status = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface='telegram')) + assert status['data']['top_candidates'] == [] and status['data']['top_threads'] == [] + assert _snapshot(store) == before + assert marker not in json.dumps(status) + local = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface='local')) + assert 'latest_decision' not in local['data'] + assert _snapshot(store) == before + + +@pytest.mark.parametrize('sibling', ['latest_decision', 'legacy_dispatch_result']) +@pytest.mark.parametrize('binding', [{}, [], '', False, {'source_candidate_id': ''}, + {'source_candidate_id': []}, {'source_candidate_id': 'missing'}]) +def test_explicit_invalid_or_missing_binding_fails_closed(tmp_path, monkeypatch, sibling, binding): + store = store_at(tmp_path) + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: ({}, {})) + record = {'type': 'subconscious.advisory', 'action': 'save', 'output_action': 'SAVE', + 'reason': 'Fictional invalid subject marker', 'admission_binding': binding} + if sibling == 'latest_decision': + store.append_jsonl('decisions', record) + else: + store.write_state({'last_dispatch_result': record}) + before = _snapshot(store) + status = json.loads(tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root))) + assert sibling not in status['data'] + assert 'Fictional invalid subject marker' not in json.dumps(status) + assert _snapshot(store) == before + + +@pytest.mark.parametrize('direct', ['candidate_id', 'origin_candidate_id', 'thread_id']) +@pytest.mark.parametrize('hidden', ['direct', 'nested', 'neither']) +def test_all_declared_status_subjects_gate_historical_prose(tmp_path, monkeypatch, direct, hidden): + store = store_at(tmp_path) + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'public_safe'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + for name in ['direct', 'nested']: + row = {'id': f'cand-{name}', 'status': 'archived', 'sensitivity': 'public_safe', + 'allowed_surfaces': ['local'] if hidden == name else ['local', 'telegram']} + store.append_jsonl('candidates', row) + if name == 'direct': + thread = candidate_to_thread(row) + thread.update(id='thread-direct', status='closed') + store.append_jsonl('threads', thread) + record = {'type': 'subconscious.advisory', 'action': 'save', 'output_action': 'SAVE', + direct: 'thread-direct' if direct == 'thread_id' else 'cand-direct', + 'admission_binding': {'source_candidate_id': 'cand-nested', 'item_key': 'historical'}, + 'reason': 'Fictional historical subject marker'} + store.append_jsonl('decisions', record) + store.append_jsonl('artifacts', {'id': 'artifact-synthetic', 'content': 'Preserved artifact'}) + store.write_state({'last_dispatch_result': record, 'budgets': {'synthetic': 1}, + 'delivered_history': ['Preserved delivery']}) + before = _snapshot(store) + status = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface='telegram')) + for sibling in ['latest_decision', 'legacy_dispatch_result']: + assert (sibling in status['data']) == (hidden == 'neither') + assert ('Fictional historical subject marker' in json.dumps(status)) == (hidden == 'neither') + local = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface='local')) + assert 'Fictional historical subject marker' in json.dumps(local) + assert _snapshot(store) == before + + +@pytest.mark.parametrize('output_action', ['DROP', 'SAVE', 'CREATE_CONSCIOUS_TASK']) +def test_unlinked_typed_advisory_choice_is_not_global(tmp_path, monkeypatch, output_action): + store = store_at(tmp_path) + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: ({}, {})) + store.append_jsonl('decisions', {'type': 'subconscious.advisory', 'output_action': output_action, + 'reason': 'Fictional lost source marker'}) + before = _snapshot(store) + status = json.loads(tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root))) + assert 'latest_decision' not in status['data'] + assert 'Fictional lost source marker' not in json.dumps(status) + assert _snapshot(store) == before + + +def test_ordinary_unlinked_operational_diagnostics_remain_global(tmp_path, monkeypatch): + store = store_at(tmp_path) + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: ({}, {})) + disabled = json.loads(tools.handle_sensorium_subconscious_advisory( + instance=store.instance, state_dir=str(store.root), enabled=False, + config={'model_enabled': False}, record_receipt=True)) + assert disabled['success'] and disabled['data']['action'] == 'disabled' + receipt = store.read_jsonl('decisions')[-1] + assert receipt['admission_binding'] is None and receipt['output_action'] is None + dispatch = json.loads(tools.handle_sensorium_dispatch_once( + instance=store.instance, state_dir=str(store.root), dry_run=True, config={})) + assert dispatch['success'] and dispatch['data']['action'] == 'no_candidate' + before = _snapshot(store) + status = json.loads(tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root))) + assert status['data']['latest_decision']['action'] == 'disabled' + assert status['data']['legacy_dispatch_result']['action'] == 'no_candidate' + assert _snapshot(store) == before + + +@pytest.mark.parametrize('sibling', ['latest_decision', 'legacy_dispatch_result']) +@pytest.mark.parametrize('mode', ['missing', 'historical', 'global']) +def test_missing_historical_and_global_status_controls(tmp_path, monkeypatch, sibling, mode): + store = store_at(tmp_path) + first = signal() + first.update(sensitivity='public_safe', allowed_surfaces=['local', 'telegram']) + ingest(store, first) + candidate = store.read_jsonl('candidates')[0] + policy = {'allowed_surfaces': ['local', 'telegram'], 'max_sensitivity': 'public_safe'} + monkeypatch.setattr(config, 'load_instance_config', lambda **kwargs: (policy, {})) + marker = 'Fictional historical operational receipt' + if sibling == 'latest_decision': + thread = candidate_to_thread(candidate) + store.append_jsonl('threads', thread) + result = json.loads(tools.handle_sensorium_thread_update( + thread_id=thread['id'], action='close', reason=marker, + instance=store.instance, state_dir=str(store.root))) + assert result['success'] + if mode == 'missing': + store.paths['threads'].write_text('') + elif mode == 'global': + store.append_jsonl('decisions', {'type': 'synthetic.operational', 'reason': marker}) + else: + dispatched = json.loads(tools.handle_sensorium_dispatch_once( + instance=store.instance, state_dir=str(store.root), dry_run=True, config={})) + assert dispatched['success'] + if mode == 'missing': + store.paths['candidates'].write_text('') + elif mode == 'historical': + reviewed = json.loads(tools.handle_sensorium_candidate_update( + candidate_id=candidate['id'], action='mark_reviewed', + instance=store.instance, state_dir=str(store.root))) + assert reviewed['success'] + else: + state = store.read_state() + state['last_dispatch_result'] = {'action': 'no_candidate', + 'reason': 'No eligible candidate above threshold.'} + store.write_state(state) + before = _snapshot(store) + status = json.loads(tools.handle_sensorium_status( + instance=store.instance, state_dir=str(store.root), surface='telegram')) + assert (sibling in status['data']) == (mode != 'missing') + assert _snapshot(store) == before + + +@pytest.mark.parametrize('action', ['drop', 'save', 'would_create_conscious_task']) +def test_legacy_advisory_action_without_output_link_is_not_global(action): + assert not tools._status_record_visible( + {'type': 'subconscious.advisory', 'action': action, 'reason': 'Fictional source prose'}, + candidates=[], threads=[], surface='local', instance_config={}) + + +@pytest.mark.parametrize('key', ['candidate_id', 'origin_candidate_id', 'thread_id']) +@pytest.mark.parametrize('value', ['', [], False]) +def test_malformed_direct_subject_is_not_global(key, value): + assert not tools._status_record_visible( + {key: value, 'reason': 'Fictional subject prose'}, + candidates=[], threads=[], surface='local', instance_config={}) From 29256cbdc3152eca2676cedae5a084153580e3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Tue, 13 Oct 2026 21:32:33 +0200 Subject: [PATCH 06/16] Fix canonical admission identity and retained reference ambiguity --- agent_sensorium/admission.py | 178 ++++++--- agent_sensorium/admission_index.py | 94 ++++- agent_sensorium/canonical_records.py | 41 +++ docs/attention-and-workroom/architecture.md | 4 +- docs/attention-and-workroom/change-scope.md | 4 +- docs/attention-and-workroom/migration.md | 4 +- tests/test_admission_known_seams.py | 18 +- tests/test_canonical_owner_foundation.py | 378 ++++++++++++++++++++ tests/test_p6_admission_closure.py | 4 +- 9 files changed, 642 insertions(+), 83 deletions(-) create mode 100644 agent_sensorium/canonical_records.py create mode 100644 tests/test_canonical_owner_foundation.py diff --git a/agent_sensorium/admission.py b/agent_sensorium/admission.py index 7eea24c..037f18a 100644 --- a/agent_sensorium/admission.py +++ b/agent_sensorium/admission.py @@ -8,8 +8,10 @@ import hashlib import json -from typing import Any +from dataclasses import dataclass +from typing import Any, Literal +from .canonical_records import lookup_record, record_index from .gate import candidate_fingerprint POLICY_VERSION = "source-admission-v2" @@ -179,17 +181,74 @@ def _claim_for_signal(instance: str, signal: dict) -> tuple[dict | None, str | N return None, None +@dataclass(frozen=True) +class SignalClaim: + """Canonical classification for replay owners; malformed is never legacy.""" + + kind: Literal["source", "legacy", "malformed"] + item_key: str | None = None + revision_key: str | None = None + member_keys: tuple[str, ...] = () + evidence_class: str | None = None + error: str | None = None + + +def classify_signal_claim(instance: str, signal: dict) -> SignalClaim: + claim, error = _claim_for_signal(instance, signal) + if error: + return SignalClaim("malformed", error=error) + if claim is None: + return SignalClaim("legacy") + return SignalClaim( + "source", claim["item_key"], claim["revision_key"], + tuple(claim.get("member_keys") or []), claim["evidence_class"], + ) + + +def equivalent_source_claims(left: SignalClaim, right: SignalClaim) -> bool: + """Prove producer material equality, not legacy wrapper equivalence.""" + return left.kind == right.kind == "source" and left == right + + +class _UniqueSnapshot(dict): + """Indexes owned by one immutable admission view, never serialized trust.""" + + def __init__(self, snap: dict): + from .admission_index import _AdmissionSnapshot + + super().__init__(snap) + self.projected = isinstance(snap, _AdmissionSnapshot) + self.indexes = { + stream: record_index(snap[stream], projected=self.projected) + for stream in ("candidates", "events", "signals") + } + + +def _unique_snapshot(snap: dict) -> _UniqueSnapshot: + return snap if isinstance(snap, _UniqueSnapshot) else _UniqueSnapshot(snap) + + +def _record_index(snap: dict, stream: str) -> dict: + from .admission_index import _AdmissionSnapshot + + if isinstance(snap, _UniqueSnapshot): + return snap.indexes[stream] + return record_index(snap[stream], projected=isinstance(snap, _AdmissionSnapshot)) + + def _snapshot(store, *, scan_bytes: int = 4 * 1024 * 1024) -> dict[str, list[dict]]: view = store.read_admission_snapshot(scan_bytes=scan_bytes) if not view.complete or view.snapshot is None: raise ValueError(view.reason or "admission_index_catching_up") - return view.snapshot + return _unique_snapshot(view.snapshot) def _snapshot_claim(instance: str, signal: dict, snap: dict) -> tuple[dict | None, str | None]: from .admission_index import _AdmissionSnapshot - if isinstance(snap, _AdmissionSnapshot): + if isinstance(snap, _AdmissionSnapshot) or ( + isinstance(snap, _UniqueSnapshot) and snap.projected + ): return signal.get("_admission_claim"), signal.get("_admission_error") return _claim_for_signal(instance, signal) @@ -201,30 +260,23 @@ def _same_disposition(prior: dict, binding: dict) -> bool: def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dict]]) -> tuple[dict | None, str | None]: + snap = _unique_snapshot(snap) candidate_id, candidate_ok = _component(candidate.get("id")) if not candidate_ok: return None, "malformed_candidate_id" assert candidate_id is not None - cached_events = snap.get("_event_index") - event_index = cached_events if isinstance(cached_events, dict) else { - row.get("id"): row for row in snap["events"] if isinstance(row, dict) - } - cached_signals = snap.get("_signal_index") - signal_index = cached_signals if isinstance(cached_signals, dict) else { - row.get("id"): row for row in snap["signals"] if isinstance(row, dict) - } + if lookup_record(_record_index(snap, "candidates"), candidate_id).state != "unique": + return None, "source_candidate_unavailable" + event_index = _record_index(snap, "events") + signal_index = _record_index(snap, "signals") joined_signal_ids: set[str] = set() - seen_signal_ids: set[str] = set() - duplicate_signal_ids: set[str] = set() - for row in snap["signals"]: - identifier = row.get("id") if isinstance(row, dict) else None - if isinstance(identifier, str): - if identifier in seen_signal_ids: - duplicate_signal_ids.add(identifier) - seen_signal_ids.add(identifier) dangling = False - for event_id in candidate.get("event_ids") or []: - event = event_index.get(event_id) + event_ids = candidate.get("event_ids") or [] + if not isinstance(event_ids, list): + event_ids = [] + dangling = True + for event_id in event_ids: + event = lookup_record(event_index, event_id).row if not isinstance(event, dict): dangling = True continue @@ -234,10 +286,7 @@ def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dic continue event_claims = set() for signal_id in signal_ids: - if not isinstance(signal_id, str) or signal_id in duplicate_signal_ids: - dangling = True - continue - signal = signal_index.get(signal_id) + signal = lookup_record(signal_index, signal_id).row if not isinstance(signal, dict): dangling = True continue @@ -255,7 +304,8 @@ def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dic # signals in the canonical retained receipt order supplied by read_jsonl. joined = [ signal for signal in snap["signals"] - if isinstance(signal, dict) and signal.get("id") in joined_signal_ids + if isinstance(signal, dict) and isinstance(signal.get("id"), str) + and signal.get("id") in joined_signal_ids ] claims: list[dict] = [] for signal in joined: @@ -310,6 +360,7 @@ def _base_binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dic def _binding_from_snapshot(store, candidate: dict, snap: dict[str, list[dict]]) -> tuple[dict | None, str | None]: + snap = _unique_snapshot(snap) binding, error = _base_binding_from_snapshot(store, candidate, snap) if error or binding is None or "member_keys" not in binding: return binding, error @@ -329,14 +380,29 @@ def binding_for_candidate(store, candidate_id: str) -> tuple[dict | None, str | indexed.reason, ) snap = _snapshot(store) - candidate = next((row for row in snap["candidates"] if row.get("id") == candidate_id), None) + candidate = lookup_record(_record_index(snap, "candidates"), candidate_id).row if not isinstance(candidate, dict) or candidate.get("status", "candidate") != "candidate": return None, "source_candidate_unavailable" return _binding_from_snapshot(store, candidate, snap) +def _member_reference_shape(binding: dict) -> bool: + """Validate every present member list before set, sort or SQL attribution.""" + for field in ("member_keys", "selected_member_keys"): + if field not in binding: + continue + members = binding[field] + if not isinstance(members, list) or any( + not isinstance(key, str) or len(key) != 64 for key in members + ): + return False + if members != sorted(set(members)): + return False + return True + + def _binding_shape(binding: Any) -> bool: - if not isinstance(binding, dict): + if not isinstance(binding, dict) or not _member_reference_shape(binding): return False if binding.get("schema_version") != 1 or binding.get("policy_version") != POLICY_VERSION: return False @@ -430,17 +496,17 @@ def _memory_members_for_prior( store, prior: dict, current: dict, snap: dict[str, list[dict]], ) -> tuple[set[str], str | None]: """Attribute persisted memory rows without rewriting historical bytes.""" + if not _member_reference_shape(prior): + return set(), "malformed_member_binding" if prior.get("policy_version") == POLICY_VERSION: selected = prior.get("selected_member_keys") - if isinstance(selected, list) and selected == sorted(set(selected)): - return {key for key in selected if isinstance(key, str)}, None + if isinstance(selected, list): + return set(selected), None return set(), "malformed_v2_member_binding" if prior.get("policy_version") != "source-admission-v1": return set(), None source_candidate_id = prior.get("source_candidate_id") - source_candidate = next( - (row for row in snap["candidates"] if row.get("id") == source_candidate_id), None, - ) + source_candidate = lookup_record(_record_index(snap, "candidates"), source_candidate_id).row if isinstance(source_candidate, dict): reconstructed, error = _base_binding_from_snapshot(store, source_candidate, snap) if not error and reconstructed and reconstructed.get("member_keys"): @@ -475,6 +541,8 @@ def _memory_prior_rows( if _is_successful_applying_disposition(row): disposed.update(overlap) for row in snap["candidates"]: + if lookup_record(_record_index(snap, "candidates"), row.get("id")).state != "unique": + continue if row.get("kind") != "subconscious_advisory": continue prior = _advisory_binding(row) @@ -505,6 +573,7 @@ def _memory_prior_rows( def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tuple[list[dict], bool, str | None]: + snap = _unique_snapshot(snap) if "member_keys" in binding: relevant, disposed, _ = _memory_prior_rows(store, binding, snap) selected = set(binding.get("selected_member_keys") or []) @@ -524,6 +593,8 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup ): decided, reason = True, "prior_disposition" for row in snap["candidates"]: + if lookup_record(_record_index(snap, "candidates"), row.get("id")).state != "unique": + continue if row.get("kind") != "subconscious_advisory": continue prior = _advisory_binding(row) @@ -540,7 +611,7 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup decided, reason = True, "prior_disposition" elif row.get("status") in _TERMINAL_STATUSES: decided, reason = True, "explicit_item_closure" - elif binding["identity_mode"] == "legacy" and list(row.get("source_candidate_ids") or []) == [binding["source_candidate_id"]]: + elif binding["identity_mode"] == "legacy" and row.get("source_candidate_ids") == [binding["source_candidate_id"]]: old_fp = str(row.get("source_candidate_fingerprint") or "") if not old_fp or old_fp == binding["source_candidate_fingerprint"]: relevant.append({ @@ -575,7 +646,8 @@ def _priority(candidate: dict) -> tuple[float, str, str]: def _has_native_memory_lineage(candidate: dict, snap: dict[str, list[dict]]) -> bool: """Identify the automatic producer without changing legacy binding shape.""" - event_ids = set(candidate.get("event_ids") or []) + refs = candidate.get("event_ids") + event_ids = {value for value in refs if isinstance(value, str)} if isinstance(refs, list) else set() event_rows = snap.get("_event_index") events = ( [event_rows[event_id] for event_id in event_ids if event_id in event_rows] @@ -586,6 +658,7 @@ def _has_native_memory_lineage(candidate: dict, snap: dict[str, list[dict]]) -> for event in events if isinstance(event, dict) and event.get("id") in event_ids for signal_id in (event.get("source_signal_ids") or []) + if isinstance(signal_id, str) } signal_rows = snap.get("_signal_index") signals = ( @@ -676,13 +749,8 @@ def build_admission_plan( "source_record_counts": None, "projected_candidate_count": 0, } - snap = view.snapshot - snap["_event_index"] = { - row.get("id"): row for row in snap["events"] if isinstance(row, dict) - } - snap["_signal_index"] = { - row.get("id"): row for row in snap["signals"] if isinstance(row, dict) - } + snap = _unique_snapshot(view.snapshot) + projected: list[tuple[dict, dict, list[dict]]] = [] eligible_count = 0 suppressed: dict[str, int] = {} @@ -756,9 +824,12 @@ def context_for_binding(store, binding: dict, *, source_decisions: list[dict] | ), } snap = _snapshot(store) - candidate = next(row for row in snap["candidates"] if row.get("id") == binding["source_candidate_id"]) - wanted_events = set(candidate.get("event_ids") or []) - events = [row for row in snap["events"] if row.get("id") in wanted_events] + candidate = lookup_record(_record_index(snap, "candidates"), binding["source_candidate_id"]).row + if candidate is None: + raise ValueError("source_candidate_unavailable") + event_index = _record_index(snap, "events") + events = [found.row for identifier in candidate.get("event_ids") or [] + if (found := lookup_record(event_index, identifier)).row is not None] if source_decisions is None: source_decisions, _, _ = _prior_for_binding(store, binding, snap) return { @@ -788,26 +859,15 @@ def event_source_item_identity( ) -> tuple[str, str] | None: """Return one unambiguous item/revision claim joined through event signals.""" rows = signals if signals is not None else store.read_jsonl("signals") - signal_index = { - row.get("id"): row - for row in rows - if isinstance(row, dict) and isinstance(row.get("id"), str) - } - ids = [row.get("id") for row in rows if isinstance(row, dict) and isinstance(row.get("id"), str)] - seen_ids: set[str] = set() - duplicate_ids: set[str] = set() - for identifier in ids: - if identifier in seen_ids: - duplicate_ids.add(identifier) - seen_ids.add(identifier) + signal_index = record_index(rows) signal_ids = event.get("source_signal_ids") or [] if not isinstance(signal_ids, list) or not signal_ids or any( - not isinstance(identifier, str) or identifier in duplicate_ids for identifier in signal_ids + lookup_record(signal_index, identifier).state != "unique" for identifier in signal_ids ): return None identities: set[tuple[str, str]] = set() for signal_id in signal_ids: - signal = signal_index.get(signal_id) + signal = lookup_record(signal_index, signal_id).row if not isinstance(signal, dict): return None identity = signal_source_item_identity(store.instance, signal) diff --git a/agent_sensorium/admission_index.py b/agent_sensorium/admission_index.py index 83cd2ca..9b0e521 100644 --- a/agent_sensorium/admission_index.py +++ b/agent_sensorium/admission_index.py @@ -14,13 +14,15 @@ from dataclasses import dataclass from typing import Callable +from .canonical_records import record_id + STREAMS = ("signals", "events", "decisions", "candidates") DEFAULT_SCAN_BYTES = 4 * 1024 * 1024 MAX_RECORD_BYTES = 1024 * 1024 BOUNDARY_BYTES = 64 SQLITE_TIMEOUT_SECONDS = 0.2 -# Per-event revision joins and disposition attribution changed; rebuild state. -SCHEMA_VERSION = 5 +# Entity ambiguity is durable projection state; old ready caches cannot certify it. +SCHEMA_VERSION = 6 MAX_DEPENDENT_REFRESHES_PER_PASS = 64 @@ -53,12 +55,18 @@ def _safe_dict(value: object) -> dict: return value if isinstance(value, dict) else {} -def _bounded_strings(value: object, *, limit: int | None = None) -> list[str]: +def _bounded_strings(value: object, *, limit: int | None = None) -> list: """Project complete record-local identifiers; the record byte cap is the bound.""" if not isinstance(value, list): return [] selected = value if limit is None else value[:limit] - return [item for item in selected if isinstance(item, str)] + # Invalid members must remain visible to record-local join validation. + return list(selected) + + +def _reference_ids(value: object) -> set[str]: + """SQL relationships carry valid IDs, not proof of complete raw joins.""" + return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set() def _binding_projection(value: object) -> dict | None: @@ -393,8 +401,10 @@ def _insert_dispositions( self, conn: sqlite3.Connection, source: str, generation: int, ordinal: int, projection: dict, binding: dict, ) -> set[str]: + from .admission import _member_reference_shape + applied = source == "candidates" or self._is_applying_decision(projection) - if not applied: + if not applied or not _member_reference_shape(binding): return set() members = binding.get("selected_member_keys") member_rows = members if isinstance(members, list) and members else [None] @@ -586,13 +596,16 @@ def _insert_projection( ) -> None: if stream == "signals": projection = _signal_projection(row, self.store.instance, claim_parser) - signal_id = str(row.get("id") or f"ordinal:{ordinal}") + signal_id = record_id(row.get("id")) + if signal_id is None: + return previous = conn.execute( "SELECT projection_json FROM signal_claim WHERE signal_id=? AND generation=?", (signal_id, generation), ).fetchone() if previous is not None: # A duplicate canonical ID is ambiguous, not last-writer proof. + projection = {"id": signal_id, "_admission_ambiguous": True} projection["_admission_claim"] = None projection["_admission_error"] = "ambiguous_signal_id" conn.execute( @@ -613,11 +626,16 @@ def _insert_projection( return if stream == "events": projection = _event_projection(row) - event_id = str(row.get("id") or f"ordinal:{ordinal}") + event_id = record_id(row.get("id")) + if event_id is None: + return previous = conn.execute( "SELECT projection_json FROM event_join WHERE event_id=? AND generation=?", (event_id, generation), ).fetchone() + if previous is not None: + projection = {"id": event_id, "source_signal_ids": [], + "_admission_ambiguous": True} conn.execute( "INSERT OR REPLACE INTO event_join VALUES (?,?,?,?)", (event_id, generation, ordinal, _json(projection)), @@ -625,7 +643,7 @@ def _insert_projection( conn.execute("DELETE FROM event_signal WHERE event_id=?", (event_id,)) conn.executemany( "INSERT OR IGNORE INTO event_signal VALUES (?,?)", - ((event_id, signal_id) for signal_id in set(projection["source_signal_ids"])), + ((event_id, signal_id) for signal_id in _reference_ids(projection["source_signal_ids"])), ) has_dependents = conn.execute( "SELECT 1 FROM candidate_event WHERE event_id=? LIMIT 1", (event_id,), @@ -641,7 +659,41 @@ def _insert_projection( return if stream == "candidates": projection = _candidate_projection(row, fingerprint) - candidate_id = str(row.get("id") or f"ordinal:{ordinal}") + candidate_id = record_id(row.get("id")) + if candidate_id is None: + return + previous = conn.execute( + "SELECT source_ordinal,prompt_projection_json FROM candidate_projection " + "WHERE candidate_id=? AND generation=?", (candidate_id, generation), + ).fetchone() + affected: set[str] = set() + if previous is not None: + old = json.loads(previous["prompt_projection_json"]) + old_binding = old.get("admission_binding") or {} + # Legacy settlement has no item/member dependency. Refresh only + # its exact singleton target after withdrawing ambiguous evidence. + legacy_targets = old.get("source_candidate_ids") + if (old.get("kind") == "subconscious_advisory" + and isinstance(legacy_targets, list) and len(legacy_targets) == 1 + and isinstance(legacy_targets[0], str)): + affected.add(legacy_targets[0]) + item_key = old_binding.get("item_key") + if isinstance(item_key, str): + conn.execute( + "INSERT OR REPLACE INTO dependency_refresh VALUES ('item',?,'')", + (item_key,), + ) + for member in _reference_ids(old_binding.get("selected_member_keys")): + conn.execute( + "INSERT OR REPLACE INTO dependency_refresh VALUES ('member',?,'')", + (member,), + ) + conn.execute( + "DELETE FROM disposition_evidence WHERE source_stream='candidates' " + "AND generation=? AND source_ordinal=?", (generation, previous["source_ordinal"]), + ) + projection = {"id": candidate_id, "status": "candidate", "event_ids": [], + "_admission_ambiguous": True} conn.execute( "INSERT OR REPLACE INTO candidate_projection VALUES (?,?,?,?,?,?,?,?,?)", ( @@ -657,16 +709,17 @@ def _insert_projection( ) conn.executemany( "INSERT OR IGNORE INTO candidate_event VALUES (?,?)", - ((candidate_id, event_id) for event_id in set(projection["event_ids"])), + ((candidate_id, event_id) for event_id in _reference_ids(projection["event_ids"])), ) - affected: set[str] = set() binding = projection.get("admission_binding") if projection.get("kind") == "subconscious_advisory": if isinstance(binding, dict): affected = self._insert_dispositions( conn, "candidates", generation, ordinal, projection, binding, ) - elif projection.get("source_candidate_ids"): + elif (isinstance(projection.get("source_candidate_ids"), list) + and len(projection["source_candidate_ids"]) == 1 + and isinstance(projection["source_candidate_ids"][0], str)): legacy_id = projection["source_candidate_ids"][0] evidence_id = hashlib.sha256( f"candidates:{generation}:{ordinal}:legacy".encode(), @@ -683,7 +736,7 @@ def _insert_projection( self._refresh_candidate( conn, candidate_id, binding_builder, generation=generation, ) - if projection.get("kind") != "subconscious_advisory": + if projection.get("kind") != "subconscious_advisory" and previous is None: base, error = binding_builder(self.store, projection, self._joined_snapshot(conn, projection)) if not error and base: affected.update(self._resolve_v1_evidence(conn, candidate_id, base)) @@ -1115,7 +1168,7 @@ def read_context(self, candidate_id: str) -> AdmissionIndexResult: row = conn.execute( "SELECT p.prompt_projection_json,s.source_decisions_json " "FROM candidate_projection p JOIN candidate_state s USING(candidate_id) " - "WHERE p.candidate_id=? AND p.generation=?", + "WHERE p.candidate_id=? AND p.generation=? AND s.binding_json IS NOT NULL", (candidate_id, generation), ).fetchone() if row is None: @@ -1127,13 +1180,22 @@ def read_context(self, candidate_id: str) -> AdmissionIndexResult: candidate = json.loads(row[0]) events = [] event_generation = self._current_generation(conn, "events") - for event_id in candidate.get("event_ids") or []: + valid_event_ids = _reference_ids(candidate.get("event_ids")) + # First-reference order, one lookup per literal ID; retain the complete + # candidate projection separately so invalid refs still imply uncertainty. + ordered_event_ids = dict.fromkeys( + value for value in candidate.get("event_ids") or [] + if isinstance(value, str) and value in valid_event_ids + ) + for event_id in ordered_event_ids: event = conn.execute( "SELECT projection_json FROM event_join WHERE event_id=? AND generation=?", (event_id, event_generation), ).fetchone() if event is not None: - events.append(json.loads(event[0])) + projected = json.loads(event[0]) + if projected.get("_admission_ambiguous") is not True: + events.append(projected) progress = self._progress(conn) conn.close() value = { diff --git a/agent_sensorium/canonical_records.py b/agent_sensorium/canonical_records.py new file mode 100644 index 0000000..deea94d --- /dev/null +++ b/agent_sensorium/canonical_records.py @@ -0,0 +1,41 @@ +"""Record-local entity lookup for admission and later replay/visibility owners. + +Only signals, events, candidates and threads are entities; decision append +receipts are not. Identifiers are literal, never coerced or trimmed. A duplicate +is ambiguous even when both records are byte-identical. Projection tombstones +are accepted only when the caller has established an internal snapshot boundary. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class RecordLookup: + state: Literal["unique", "missing", "ambiguous"] + row: dict | None = None + + +def record_id(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def record_index(rows: list[dict], *, projected: bool = False) -> dict[str, RecordLookup]: + result: dict[str, RecordLookup] = {} + for row in rows: + if not isinstance(row, dict): + continue + identifier = record_id(row.get("id")) + if identifier is None: + continue + if identifier in result or (projected and row.get("_admission_ambiguous") is True): + result[identifier] = RecordLookup("ambiguous") + else: + result[identifier] = RecordLookup("unique", row) + return result + + +def lookup_record(index: dict[str, RecordLookup], identifier: object) -> RecordLookup: + key = record_id(identifier) + return index.get(key, RecordLookup("missing")) if key is not None else RecordLookup("missing") diff --git a/docs/attention-and-workroom/architecture.md b/docs/attention-and-workroom/architecture.md index 74af2c0..e258f35 100644 --- a/docs/attention-and-workroom/architecture.md +++ b/docs/attention-and-workroom/architecture.md @@ -50,7 +50,7 @@ Changing only wrapper text does not create new material. A genuine `revision-b` **Index progress.** The scanner advances through multiple complete JSONL records within the aggregate source-byte budget, stopping at an incomplete tail and retaining cursor/fingerprint/lock ownership. It retains record-size, SQLite timeout and dependent-refresh bounds. Measured VM steps are not a new hard execution ceiling; output limits are not permission to delete old unresolved material. -**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 5. Earlier ready caches cannot certify per-event revision joins or disposition attribution and are rebuilt from canonical JSONL records. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. +**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 6. Earlier ready caches cannot certify per-event revision joins or disposition attribution and are rebuilt from canonical JSONL records. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. **Optional latent context.** A direct read-only call can request held/dormant metadata: @@ -65,6 +65,8 @@ The option defaults off and creates no automatic native caller, resume, task or ## Proposed context and finite tools +Canonical entity IDs are literal and must be unique, including byte-identical duplicates. Ambiguous candidates have no admission slot; ambiguous advisories supply no settlement. A unique candidate with missing, malformed or ambiguous joins may retain an uncertain candidate-scoped legacy consideration, never producer identity. Projections preserve invalid reference members rather than filtering them into stronger authority. Snapshot-local uniqueness indexes are constructed once per immutable view; the existing prior scan remains quadratic, and this is not a hard VM-step ceiling. Producer-reserved projection fields remain untrusted outside structurally owned internal snapshots. Tools ingress (B) and lineage visibility (C) remain future-gated; this foundation does not implement those owners. + A trusted external approver supplies immutable context, including nested values, binding runtime/package-manager selection, interpreter/ABI/import origins, selected native route/configuration, canonical SessionDB/profile/tier/episode owner, artifact owner, memory read owner and source authorization. A self-digest is consistency, not approval. Observed drift causes a typed **HOLD**, never silent rebinding, updated hashes, copied bindings, alternate interpreter, route or history fallback. Sources have approved aliases, original observation provenance/times, validity and exact lengths/digests. Verify current bytes before debit, before inference and each read. Copying or retimestamping does not manufacture freshness; a distinct new observation needs distinct approval. No Wiki reference or retention sentence substitutes for source authorization. diff --git a/docs/attention-and-workroom/change-scope.md b/docs/attention-and-workroom/change-scope.md index 3ee4b3b..f6f4d7a 100644 --- a/docs/attention-and-workroom/change-scope.md +++ b/docs/attention-and-workroom/change-scope.md @@ -9,7 +9,7 @@ This candidate implements three attention changes and documents a native/workroo | Feature group | Status | Behavior, reason and boundary | |---|---|---| | Producer item/revision | **Implemented; repair review pending** | Complete unique per-event source joins establish material equality. Missing revision stays unversioned; conflicting aliases/mixed joins refuse equality. Raw producer payloads cannot impersonate derived cached claims. Equivalent-visibility replay is domain-write/observer-free; stricter replay narrows future lineage visibility without pressure/observer effects. Changed same-item material can coalesce. No thematic equality or manufactured old revision. | -| Bounded index cursor | **Implemented; repair review pending** | Multiple complete records advance within aggregate source-byte budget, retaining validated boundaries when an unchanged offset exhausts the budget; partial tail waits. Cache generation 5 rebuilds obsolete join/disposition semantics from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | +| Bounded index cursor | **Implemented; repair review pending** | Multiple complete records advance within aggregate source-byte budget, retaining validated boundaries when an unchanged offset exhausts the budget; partial tail waits. Cache generation 6 rebuilds obsolete uniqueness/join/disposition semantics from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | | Latent thread awareness | **Implemented; optional; source-reviewed** | Read-only held/dormant metadata, default off, closed visibility/duplicate rejection and honest provenance/unknowns. Six threads/two receipts/6,144-byte output; unbounded input scans, incomplete semantics. No automatic native consumer/resume. | | Native runtime contract | **Proposed; host capability held** | Immutable external approval for selection/generation/interpreter/ABI/import/distribution/route origins; drift HOLD, no automatic rebinding or fallback. Actual host verification not supplied. | | Native session continuity | **Proposed; native capability held** | Stable native root and complete owner/compression/model-history recovery before transcript access. No title adoption, silent fork, wake-summary replacement or reseed. Exact ID/DB/history adapter unproven. | @@ -27,6 +27,8 @@ This candidate implements three attention changes and documents a native/workroo ## Retained protections +The canonical admission foundation rejects ambiguous candidate/advisory authority, preserves malformed joins without projection filtering and constructs uniqueness indexes once per immutable snapshot. Unique uncertain candidates may retain legacy first consideration, never a fresh producer revision. Existing quadratic prior scans and measured SQL work are not a new hard execution budget. Tools ingress (B) and lineage visibility (C) remain future-gated and unchanged. + - **Instance/config/store/live/admin:** canonical instance validation before effects, no live arbitrary state-root injection, small ordinary tool surface, explicit admin and generic configurable profiles. - **Admission/current attention:** source authority and anti-paraphrase suppression, unresolved/held eligibility, bounded attempts and exact index/completeness owners; no capacity-burn projection/window remapping restored. - **Aperture/doorway/pointers/consumer:** exact lease/generation/consumer/presentation settlement, stale reclaim without fabricated semantic resolution, future HELD checkpoints, STOP and session-local pointers. diff --git a/docs/attention-and-workroom/migration.md b/docs/attention-and-workroom/migration.md index 99386f4..207ef52 100644 --- a/docs/attention-and-workroom/migration.md +++ b/docs/attention-and-workroom/migration.md @@ -6,12 +6,14 @@ Attention can be updated without activating a workroom. No real accounting migra Keep canonical instance/store identities and signal/event/candidate/thread/decision/artifact/outbox ownership. Do not transplant alternate whole trees, deployment fixtures or obsolete assertions over public-only repairs. Producer revisions are not manufactured for old rows. Index progress retains cursors, fingerprints, stream boundaries and old unresolved material; no reset, newest-N deletion or disguised lifetime rescan. -The attention cache is disposable, not canonical accounting. Generation 5 invalidates older ready projections because per-event revision validation and disposition attribution changed. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. +The attention cache is disposable, not canonical accounting. Generation 6 invalidates older ready projections because entity uniqueness, per-event revision validation and disposition attribution changed. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. Exact consumer/aperture generation/lease/presentation ownership remains required. Lease expiry releases execution, not semantic settlement. Future HELD checkpoints and STOP persist. Keep session-local pointers, closed privacy policy, dashboard/URL validation and honest unknown status. Optional implemented latent projection remains read-only, default-off and honestly incomplete; output caps do not bound input I/O. ## Native adoption is proposed, never implicit +Duplicate entity IDs remain ambiguous until canonical rewrite restores uniqueness; append replacement IDs are not a supported revision contract. Rebuild and dependency refresh withdraw ambiguous advisory settlement without rewriting canonical history. Missing or malformed joins of a unique candidate remain uncertain legacy where policy permits, not source equality. Fixture completion of a missing distinct event is legacy-to-source; a valid source-to-source transition instead rewrites the unique canonical event or updates candidate references to a fresh distinct event. Tools ingress (B) and visibility (C) need separate acceptance. + A future generic extension must import cold without private files/dependencies, path discovery, registry mutation, provider initialization or inference. No embedded deployment root, endpoint, memory target, grant or ledger path. Immutable externally approved context pins runtime/package-manager generation/interpreter/ABI/import origins, selected route/model/configuration, canonical DB/profile/tier/root, artifact owner, memory read owner and source authorization. Current observations must match; drift HOLDs. Code upgrades or relocation require fresh approval, never copied bindings or automatic digest refresh. Accounting authority persists through route/runtime changes. Source authorization needs an explicitly approved versioned schema retaining original observations/times, validity, aliases and exact lengths/digests. A renamed Wiki field or retention instruction is not equivalent authority. Check current source bytes before debit, inference and every read; copying or retimestamping cannot refresh evidence. A real new observation may be separately approved without rewriting old provenance. diff --git a/tests/test_admission_known_seams.py b/tests/test_admission_known_seams.py index c3122da..57f08a4 100644 --- a/tests/test_admission_known_seams.py +++ b/tests/test_admission_known_seams.py @@ -219,15 +219,21 @@ def forbidden_runner(command, **kwargs): def test_append_winner_refreshes_projection_without_candidate_rewrite(tmp_path): store = _store(tmp_path, "append-winner") _seed_one(store) + # Initially incomplete membership becomes source-owned; this is not r1 -> r2. + store.rewrite_jsonl("candidates", [ + {**store.read_jsonl("candidates")[0], "event_ids": ["event", "replacement-event"]} + ]) + candidate_bytes = store.paths["candidates"].read_bytes() _finish(store) old = build_admission_plan(store)["selection"] assert old is not None + assert old["identity_mode"] == "legacy" second = _frontier("signal-two", "revision-two") store.append_jsonl("signals", second) store.append_jsonl( "events", { - "id": "event", + "id": "replacement-event", "kind": "creative_pull", "summary": "replacement", "source_signal_ids": ["signal-two"], @@ -235,6 +241,8 @@ def test_append_winner_refreshes_projection_without_candidate_rewrite(tmp_path): ) prepared = _finish(store) current = build_admission_plan(store)["selection"] + assert current["identity_mode"] == "source" + assert store.paths["candidates"].read_bytes() == candidate_bytes claim, error = _claim_for_signal(store.instance, second) assert prepared.dependent_refreshes == 1 assert error is None and claim is not None and current is not None @@ -274,13 +282,15 @@ def test_dependency_fanout_is_queued_and_fail_closed_until_complete(tmp_path): "kind": "creative_pull", "pressure": 0.5, "created_at": "2026-01-01T00:00:00Z", - "event_ids": ["shared"], + "event_ids": ["shared", "replacement-shared"], }, ) + candidate_bytes = store.paths["candidates"].read_bytes() _finish(store) + assert build_admission_plan(store)["selection"]["identity_mode"] == "legacy" second = _frontier("signal-two", "revision-two") store.append_jsonl("signals", second) - store.append_jsonl("events", {"id": "shared", "source_signal_ids": ["signal-two"]}) + store.append_jsonl("events", {"id": "replacement-shared", "source_signal_ids": ["signal-two"]}) first = store.prepare_admission_index(scan_bytes=4 * 1024 * 1024) assert not first.complete assert first.reason == "dependent_refresh_pending" @@ -288,6 +298,8 @@ def test_dependency_fanout_is_queued_and_fail_closed_until_complete(tmp_path): incomplete = build_admission_plan(store) assert incomplete["state"] == "catching_up" and incomplete["selection"] is None final = _finish(store) + assert store.paths["candidates"].read_bytes() == candidate_bytes + assert build_admission_plan(store)["selection"]["identity_mode"] == "source" assert final.dependent_refreshes == 36 claim, error = _claim_for_signal(store.instance, second) assert error is None and claim is not None diff --git a/tests/test_canonical_owner_foundation.py b/tests/test_canonical_owner_foundation.py new file mode 100644 index 0000000..860ec24 --- /dev/null +++ b/tests/test_canonical_owner_foundation.py @@ -0,0 +1,378 @@ +"""Synthetic canonical foundation; no ingress or disclosure-policy specifications.""" +import json +import sqlite3 + +import pytest + +from agent_sensorium import admission +from agent_sensorium.admission_index import AdmissionIndex +from agent_sensorium.canonical_records import lookup_record, record_index +from tests.test_core_cache_injection import canonical, raw_snapshot +from tests.test_core_material_replay import signal, store_at + + +def material_rows(stream, identical=False): + signals = [signal("s1", "r1"), signal("s2", "r2")] + events = [{"id": "e1", "source_signal_ids": ["s1"]}, + {"id": "e2", "source_signal_ids": ["s2"]}] + candidates = [{"id": "c", "status": "candidate", "kind": "creative_pull", + "event_ids": ["e1"]}] + if stream == "signals": + signals[1]["id"] = "s1" + events = [events[0]] + elif stream == "events": + events[1]["id"] = "e1" + else: + candidates.append({**candidates[0], "event_ids": ["e2"]}) + rows = {"signals": signals, "events": events, "candidates": candidates, "decisions": []} + if identical: + rows[stream][1] = dict(rows[stream][0]) + return rows + + +def read_binding(store, mode): + if mode == "raw": + snap = raw_snapshot(store) + candidate = lookup_record(record_index(snap["candidates"]), "c").row + return ((None, "source_candidate_unavailable") if candidate is None + else admission._binding_from_snapshot(store, candidate, snap)) + if mode == "cacheless": + assert not AdmissionIndex(store).path.exists() + assert store.read_admission_snapshot().complete + return admission.binding_for_candidate(store, "c") + + +@pytest.mark.parametrize("stream", ["signals", "events", "candidates"]) +@pytest.mark.parametrize("mode", ["raw", "cacheless", "incremental", "rewrite_generation"]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("identical", [False, True]) +def test_canonical_duplicate_ids_cannot_select_material(tmp_path, stream, mode, reverse, identical): + store = store_at(tmp_path) + rows = material_rows(stream, identical) + if reverse: + rows[stream].reverse() + duplicates = list(rows[stream]) + if mode == "incremental": + rows[stream] = duplicates[:1] + for name, values in rows.items(): + for row in values: + store.append_jsonl(name, row) + if mode not in {"raw", "cacheless"}: + assert store.prepare_admission_index().complete + if mode == "incremental": + store.append_jsonl(stream, duplicates[1]) + assert admission.binding_for_candidate(store, "c")[0] is None + else: + store.rewrite_jsonl(stream, list(reversed(duplicates))) + with sqlite3.connect(AdmissionIndex(store).path) as conn: + conn.execute("PRAGMA user_version=5") + assert admission.binding_for_candidate(store, "c")[0] is None + assert store.prepare_admission_index().complete + before = canonical(store) + binding, error = read_binding(store, mode) + assert canonical(store) == before + assert binding is None or binding["identity_mode"] != "source" + + +@pytest.mark.parametrize("mode", ["raw", "cacheless", "incremental"]) +def test_distinct_event_successive_revision_allowed_inverse(tmp_path, mode): + store = store_at(tmp_path) + for row in [signal("s1", "r1"), signal("s2", "r2")]: + store.append_jsonl("signals", row) + for row in [{"id": "e1", "source_signal_ids": ["s1"]}, + {"id": "e2", "source_signal_ids": ["s2"]}]: + store.append_jsonl("events", row) + store.append_jsonl("candidates", {"id": "c", "status": "candidate", "event_ids": ["e2", "e1"]}) + if mode == "incremental": + assert store.prepare_admission_index().complete + before = canonical(store) + binding, error = read_binding(store, mode) + assert error is None + assert binding["revision_key"] == admission._claim_for_signal( + store.instance, signal("s2", "r2"))[0]["revision_key"] + assert canonical(store) == before + + +@pytest.mark.parametrize("stream", ["signals", "events", "candidates"]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_late_third_duplicate_and_rewrite_uniqueness_recovery(tmp_path, stream, reverse): + store = store_at(tmp_path) + rows = material_rows(stream) + if reverse: + rows[stream].reverse() + first, second = rows[stream] + for name, values in rows.items(): + for row in (values[:1] if name == stream else values): + store.append_jsonl(name, row) + assert store.prepare_admission_index().complete + admitted, error = admission.binding_for_candidate(store, "c") + assert error is None and admitted["identity_mode"] == "source" + for duplicate in [second, first, second]: + store.append_jsonl(stream, duplicate) + assert admission.binding_for_candidate(store, "c")[0] is None + assert store.prepare_admission_index().complete + before = canonical(store) + current, _ = admission.binding_for_candidate(store, "c") + assert current is None or current["identity_mode"] != "source" + assert canonical(store) == before + store.rewrite_jsonl(stream, [first]) + assert admission.binding_for_candidate(store, "c")[0] is None + assert store.prepare_admission_index().complete + assert admission.binding_for_candidate(store, "c") == (admitted, None) + + +@pytest.mark.parametrize("variant,kind", [("source", "source"), ("legacy", "legacy"), + ("invalid", "malformed"), ("conflict", "malformed"), ("injected", "legacy"), + ("memory", "source"), ("telemetry", "source")]) +def test_typed_canonical_claim_classification(variant, kind): + row = signal() + if variant in {"legacy", "injected"}: + row.pop("artifact_meta") + row.pop("source_revision") + if variant == "injected": + row.update(_admission_claim=admission._claim_for_signal("core", signal())[0], + _admission_error="forged", _admission_ambiguous=True) + if variant == "invalid": + row["source_revision"] = None + if variant == "conflict": + row["artifact_meta"]["revision_id"] = "other" + if variant in {"memory", "telemetry"}: + row.pop("artifact_meta") + if variant == "memory": + row.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider": "fixture", "bank_id": "bank", + "item_ids": ["b", "a"]}) + else: + row.update(kind="fixture_pressure", metric_family="metric", source_revision="1") + got = admission.classify_signal_claim("core", row) + assert got.kind == kind + assert admission.equivalent_source_claims(got, got) is (kind == "source") + if variant == "telemetry": + assert got.evidence_class == "telemetry" + if variant == "memory": + assert len(got.member_keys) == 2 + permuted = {**row, "memory_provenance": {**row["memory_provenance"], "item_ids": ["a", "b"]}} + assert admission.classify_signal_claim("core", permuted) == got + replaced = {**row, "memory_provenance": {**row["memory_provenance"], "item_ids": ["a", "c"]}} + assert not admission.equivalent_source_claims(got, admission.classify_signal_claim("core", replaced)) + projected = {"signals": [row], "events": [], "candidates": [], "decisions": []} + assert admission._snapshot_claim("core", row, json.loads(json.dumps(projected))) == admission._claim_for_signal("core", row) + + +def test_literal_producer_components_and_lookup_identifiers(): + row = signal(" wrapper ", " r1 ", " item ") + row["artifact_meta"]["source_id"] = " feed " + before = json.dumps(row, sort_keys=True) + claim = admission.classify_signal_claim("core", row) + assert claim.kind == "source" + assert claim != admission.classify_signal_claim("core", signal("wrapper", "r1", "item")) + assert json.dumps(row, sort_keys=True) == before + index = record_index([{"id": " c "}, {"id": "c"}]) + assert lookup_record(index, " c ").row == {"id": " c "} + assert lookup_record(index, "c").row == {"id": "c"} + + +@pytest.mark.parametrize("identifier", [None, [], {}, 3, True, ""]) +def test_record_local_lookup_is_total_and_never_normalizes(identifier): + index = record_index([{"id": identifier}, {"id": "3"}, {"id": "same"}, {"id": "same"}]) + assert lookup_record(index, identifier).state == "missing" + assert lookup_record(index, "3").state == "unique" + assert lookup_record(index, "same").state == "ambiguous" + assert lookup_record(index, "missing").state == "missing" + + +def foundation_seed(tmp_path, source=True): + store = store_at(tmp_path) + row = signal("s", "r1") + if not source: + row.pop("artifact_meta") + row.pop("source_revision") + store.append_jsonl("signals", row) + store.append_jsonl("events", {"id": "e", "source_signal_ids": ["s"]}) + store.append_jsonl("candidates", { + "id": "c", "kind": "creative_pull", "status": "candidate", "event_ids": ["e"] + }) + return store + + +def foundation_finish(store): + for _ in range(20): + result = store.prepare_admission_index() + if result.complete: + return result + raise AssertionError(str(result)) + + +@pytest.mark.parametrize("bound", [False, True]) +def test_duplicate_advisory_removes_disposition(tmp_path, bound): + store = foundation_seed(tmp_path, source=bound) + binding, _ = admission.binding_for_candidate(store, "c") + advisory = {"id": "adv", "kind": "subconscious_advisory", "status": "held", + "source_candidate_ids": ["c"]} + if bound: + advisory["admission_binding"] = binding + store.append_jsonl("candidates", advisory) + foundation_finish(store) + assert admission.build_admission_plan(store)["selection"] is None + for _ in range(2): + store.append_jsonl("candidates", dict(advisory)) + foundation_finish(store) + before = canonical(store) + raw = admission._prior_for_binding(store, binding, raw_snapshot(store)) + assert raw[1] is False + assert admission.build_admission_plan(store)["selection"] is not None + assert canonical(store) == before + store.rewrite_jsonl("candidates", [store.read_jsonl("candidates")[0], advisory]) + foundation_finish(store) + assert admission.build_admission_plan(store)["selection"] is None + + +@pytest.mark.parametrize("size", [20, 40]) +def test_normal_cacheless_cost(tmp_path, monkeypatch, size): + store = foundation_seed(tmp_path) + row = store.read_jsonl("candidates")[0] + store.rewrite_jsonl("candidates", [{**row, "id": f"c{i}"} for i in range(size)]) + real = admission.record_index + counts = {"calls": 0, "rows": 0} + + def counted(rows, **kwargs): + counts["calls"] += 1 + counts["rows"] += len(rows) + return real(rows, **kwargs) + + monkeypatch.setattr(admission, "record_index", counted) + before = canonical(store) + plan = admission.build_admission_plan(store, candidate_limit=1) + assert plan["eligible_count"] == size and canonical(store) == before + assert 0 < counts["calls"] <= 3 + assert 0 < counts["rows"] <= size + 2 + + +@pytest.mark.parametrize("field", ["source_signal_ids", "event_ids"]) +@pytest.mark.parametrize("invalid", [3, {}, [], None]) +def test_malformed_join_raw_cacheless_agree(tmp_path, field, invalid): + store = foundation_seed(tmp_path) + stream, identifier, valid = (("events", "e", "s") if field == "source_signal_ids" + else ("candidates", "c", "e")) + row = store.read_jsonl(stream)[0] + store.rewrite_jsonl(stream, [{**row, "id": identifier, field: [valid, invalid]}]) + before = canonical(store) + raw, error = admission._binding_from_snapshot( + store, store.read_jsonl("candidates")[0], raw_snapshot(store)) + cached, cached_error = admission.binding_for_candidate(store, "c") + assert error is cached_error is None + assert raw["identity_mode"] == cached["identity_mode"] == "legacy" + assert raw == cached + foundation_finish(store) + assert admission.binding_for_candidate(store, "c") == (raw, None) + assert canonical(store) == before + + +@pytest.mark.parametrize("refs", [["c", 3], ["c", {}], ["c", "other"], "c"]) +def test_malformed_legacy_refs_do_not_gain_exact_settlement(tmp_path, refs): + store = foundation_seed(tmp_path, source=False) + binding, _ = admission.binding_for_candidate(store, "c") + store.append_jsonl("candidates", {"id": "adv", "kind": "subconscious_advisory", + "status": "held", "source_candidate_ids": refs}) + assert not admission._prior_for_binding(store, binding, raw_snapshot(store))[1] + assert admission.build_admission_plan(store)["selection"] == binding + foundation_finish(store) + assert admission.build_admission_plan(store)["selection"] == binding + + +@pytest.mark.parametrize("mode", ["cacheless", "indexed"]) +@pytest.mark.parametrize("transition", ["rewrite", "distinct"]) +def test_source_revision_settlement_and_terminal_closure(tmp_path, mode, transition): + store = foundation_seed(tmp_path) + old, _ = admission.binding_for_candidate(store, "c") + assert old["identity_mode"] == "source" + store.append_jsonl("candidates", {"id": "adv", "kind": "subconscious_advisory", + "status": "held", "admission_binding": old}) + if mode == "indexed": + foundation_finish(store) + candidate_bytes = store.paths["candidates"].read_bytes() + store.append_jsonl("signals", signal("s2", "r2")) + if transition == "rewrite": + store.rewrite_jsonl("events", [{"id": "e", "source_signal_ids": ["s2"]}]) + assert store.paths["candidates"].read_bytes() == candidate_bytes + else: + store.append_jsonl("events", {"id": "e2", "source_signal_ids": ["s2"]}) + rows = store.read_jsonl("candidates") + rows[0]["event_ids"] = ["e", "e2"] + store.rewrite_jsonl("candidates", rows) + if mode == "indexed": + foundation_finish(store) + current = admission.build_admission_plan(store)["selection"] + assert current["identity_mode"] == "source" + assert current["item_key"] == old["item_key"] + assert current["revision_key"] != old["revision_key"] + for binding in (old, current): + store.append_jsonl("decisions", {"id": "receipt", "dry_run": False, + "action": "drop", "output_action": "DROP", "admission_binding": binding}) + if mode == "indexed": + foundation_finish(store) + plan = admission.build_admission_plan(store) + assert plan["selection"] == (current if binding == old else None) + store.rewrite_jsonl("decisions", []) + rows = store.read_jsonl("candidates") + rows[1]["status"] = "archived" + store.rewrite_jsonl("candidates", rows) + if mode == "indexed": + foundation_finish(store) + before = canonical(store) + plan = admission.build_admission_plan(store) + assert plan["selection"] is None + assert plan["suppressed_counts"] == {"explicit_item_closure": 1} + assert canonical(store) == before + + +@pytest.mark.parametrize("stream", ["decisions", "candidates"]) +@pytest.mark.parametrize("field", ["member_keys", "selected_member_keys"]) +@pytest.mark.parametrize("invalid", [3, {}]) +def test_malformed_member_refs_supply_no_settlement(tmp_path, stream, field, invalid): + store = foundation_seed(tmp_path) + row = store.read_jsonl("signals")[0] + row.pop("artifact_meta") + row.pop("source_revision") + row.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider": "fixture", "bank_id": "bank", "item_ids": ["a"]}) + store.rewrite_jsonl("signals", [row]) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and binding is not None + malformed = {**binding, field: [*binding[field], invalid]} + store.append_jsonl(stream, {"id": "adv", "kind": "subconscious_advisory", "status": "held", + "dry_run": False, "action": "drop", "output_action": "DROP", "admission_binding": malformed}) + before = canonical(store) + raw = admission._binding_from_snapshot(store, store.read_jsonl("candidates")[0], raw_snapshot(store)) + assert raw == (binding, None) + assert admission.build_admission_plan(store)["selection"] == binding + foundation_finish(store) + assert admission.build_admission_plan(store)["selection"] == binding + assert canonical(store) == before + + +@pytest.mark.parametrize("invalid", [3, {}]) +def test_binding_shape_rejects_untyped_members_without_raising(tmp_path, invalid): + store = foundation_seed(tmp_path) + row = store.read_jsonl("signals")[0] + row.pop("artifact_meta") + row.pop("source_revision") + row.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider": "fixture", "bank_id": "bank", "item_ids": ["a"]}) + store.rewrite_jsonl("signals", [row]) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and binding is not None + assert admission._binding_shape({**binding, "selected_member_keys": [*binding["selected_member_keys"], invalid]}) is False + + +@pytest.mark.parametrize("invalid", [3, {}]) +def test_indexed_context_is_total_for_nonstring_event_refs(tmp_path, invalid): + store = foundation_seed(tmp_path) + row = store.read_jsonl("candidates")[0] + store.rewrite_jsonl("candidates", [{**row, "event_ids": ["e", invalid]}]) + foundation_finish(store) + before = canonical(store) + result = store.read_admission_context("c") + assert result.complete and result.state == "ready" + assert [event["id"] for event in result.value["events"]] == ["e"] + assert canonical(store) == before diff --git a/tests/test_p6_admission_closure.py b/tests/test_p6_admission_closure.py index 0586aaa..a8e7383 100644 --- a/tests/test_p6_admission_closure.py +++ b/tests/test_p6_admission_closure.py @@ -67,10 +67,10 @@ def _frontier_revision(store: SensoriumStore, revision: str, *, create: bool) -> "kind": "creative_pull", "summary": "frontier source", "artifact_meta": {"entry_id": "entry", "sha256": revision}, }) - store.append_jsonl("events", { + store.rewrite_jsonl("events", [{ "id": "frontier-event", "kind": "creative_pull", "summary": "frontier event", "source_signal_ids": [signal_id], - }) + }]) if create: store.append_jsonl("candidates", { "id": "frontier-candidate", "status": "candidate", "kind": "creative_pull", From 91b2da82a0e5bbd9aa3b5eaea3d080394d73aca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Tue, 13 Oct 2026 21:49:18 +0200 Subject: [PATCH 07/16] fix(admission): validate ordinary prior member references --- agent_sensorium/admission.py | 8 +- tests/test_canonical_owner_foundation.py | 98 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/agent_sensorium/admission.py b/agent_sensorium/admission.py index 037f18a..1dafb48 100644 --- a/agent_sensorium/admission.py +++ b/agent_sensorium/admission.py @@ -584,7 +584,10 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup reason = None for row in snap["decisions"]: prior = row.get("admission_binding") - if not isinstance(prior, dict) or prior.get("item_key") != binding["item_key"]: + if ( + not isinstance(prior, dict) or not _member_reference_shape(prior) + or prior.get("item_key") != binding["item_key"] + ): continue relevant.append(_decision_projection(row)) if ( @@ -598,6 +601,9 @@ def _prior_for_binding(store, binding: dict, snap: dict[str, list[dict]]) -> tup if row.get("kind") != "subconscious_advisory": continue prior = _advisory_binding(row) + # Invalid present members cannot become legacy-exact fallback authority. + if isinstance(prior, dict) and not _member_reference_shape(prior): + continue if isinstance(prior, dict) and prior.get("item_key") == binding["item_key"]: relevant.append({ "ts": row.get("updated_at") or row.get("created_at"), diff --git a/tests/test_canonical_owner_foundation.py b/tests/test_canonical_owner_foundation.py index 860ec24..9858a02 100644 --- a/tests/test_canonical_owner_foundation.py +++ b/tests/test_canonical_owner_foundation.py @@ -376,3 +376,101 @@ def test_indexed_context_is_total_for_nonstring_event_refs(tmp_path, invalid): assert result.complete and result.state == "ready" assert [event["id"] for event in result.value["events"]] == ["e"] assert canonical(store) == before + + +@pytest.mark.parametrize("stream", ["decisions", "candidates"]) +@pytest.mark.parametrize("field", ["member_keys", "selected_member_keys"]) +@pytest.mark.parametrize("invalid", [3, {}]) +def test_ordinary_source_malformed_present_members_no_settlement(tmp_path, stream, field, invalid): + store = foundation_seed(tmp_path) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and binding["identity_mode"] == "source" + assert "member_keys" not in binding + malformed = {**binding, field: [invalid]} + store.append_jsonl(stream, {"id":"adv", "kind":"subconscious_advisory", "status":"held", + "dry_run":False, "action":"drop", "output_action":"DROP", "admission_binding":malformed}) + before = canonical(store) + raw = admission._prior_for_binding(store, binding, raw_snapshot(store)) + cacheless = admission.build_admission_plan(store) + foundation_finish(store) + indexed = admission.build_admission_plan(store) + assert canonical(store) == before + print({"stream":stream,"field":field,"invalid":invalid,"raw_decided":raw[1], + "cacheless_selection":cacheless["selection"],"indexed_selection":indexed["selection"]}) + assert raw[1] is False + assert cacheless["selection"] == indexed["selection"] == binding + + +@pytest.mark.parametrize("authority", ["receipt", "held", "nested_held", "terminal", "nested_terminal"]) +@pytest.mark.parametrize("source", [False, True]) +@pytest.mark.parametrize("historical", [False, True]) +@pytest.mark.parametrize("field", [None, "member_keys", "selected_member_keys"]) +def test_ordinary_prior_member_validation_and_absent_inverse( + tmp_path, authority, source, historical, field, +): + store = foundation_seed(tmp_path, source=source) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and "member_keys" not in binding + prior = dict(binding) + if historical: + prior["policy_version"] = "source-admission-v1" + terminal = "terminal" in authority + if terminal: + prior["admission_key"] = "old-admission" + prior["revision_key"] = "old-revision" + if field is not None: + prior[field] = [3] + row = {"id": "adv", "kind": "subconscious_advisory", + "status": "archived" if terminal else "held", "source_candidate_ids": ["c"], + "dry_run": False, "action": "drop", "output_action": "DROP"} + if authority.startswith("nested_"): + row["advisory_meta"] = {"admission_binding": prior} + else: + row["admission_binding"] = prior + store.append_jsonl("decisions" if authority == "receipt" else "candidates", row) + before = canonical(store) + raw = admission._prior_for_binding(store, binding, raw_snapshot(store)) + cacheless = admission.build_admission_plan(store) + foundation_finish(store) + indexed = admission.build_admission_plan(store) + assert canonical(store) == before + valid = field is None + assert raw[1] is valid + assert bool(raw[0]) is valid + expected_reason = ("explicit_item_closure" if terminal else "prior_disposition") if valid else None + assert raw[2] == expected_reason + assert cacheless["selection"] == indexed["selection"] == (None if valid else binding) + + +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("field", [None, "member_keys", "selected_member_keys"]) +def test_legacy_exact_fallback_rejects_present_members_but_keeps_absent_inverse( + tmp_path, nested, field, +): + store = foundation_seed(tmp_path, source=False) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and binding["identity_mode"] == "legacy" + # A genuine unbound advisory is the historical legacy-exact inverse. + prior = {**binding, "item_key": "other-item", "admission_key": "other-admission"} + if field is not None: + prior[field] = [{}] + row = {"id": "adv", "kind": "subconscious_advisory", "status": "held", + "source_candidate_ids": ["c"], + "source_candidate_fingerprint": binding["source_candidate_fingerprint"]} + if field is not None: + if nested: + row["advisory_meta"] = {"admission_binding": prior} + else: + row["admission_binding"] = prior + store.append_jsonl("candidates", row) + before = canonical(store) + raw = admission._prior_for_binding(store, binding, raw_snapshot(store)) + cacheless = admission.build_admission_plan(store) + foundation_finish(store) + indexed = admission.build_admission_plan(store) + assert canonical(store) == before + valid = field is None + assert raw[1] is valid + assert bool(raw[0]) is valid + assert raw[2] == ("legacy_exact_source_disposition" if valid else None) + assert cacheless["selection"] == indexed["selection"] == (None if valid else binding) From 55ea8857968cf5fa44a1aaed046fafbe7b7d1df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 07:30:57 +0200 Subject: [PATCH 08/16] Preserve invalid member evidence in admission projections --- agent_sensorium/admission_index.py | 10 +- docs/attention-and-workroom/architecture.md | 2 +- docs/attention-and-workroom/change-scope.md | 2 +- docs/attention-and-workroom/migration.md | 2 +- tests/test_canonical_owner_foundation.py | 104 ++++++++++++++++++++ 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/agent_sensorium/admission_index.py b/agent_sensorium/admission_index.py index 9b0e521..8ce5337 100644 --- a/agent_sensorium/admission_index.py +++ b/agent_sensorium/admission_index.py @@ -21,8 +21,8 @@ MAX_RECORD_BYTES = 1024 * 1024 BOUNDARY_BYTES = 64 SQLITE_TIMEOUT_SECONDS = 0.2 -# Entity ambiguity is durable projection state; old ready caches cannot certify it. -SCHEMA_VERSION = 6 +# Invalid-present members must survive projection; old ready caches lost evidence. +SCHEMA_VERSION = 7 MAX_DEPENDENT_REFRESHES_PER_PASS = 64 @@ -84,9 +84,9 @@ def _binding_projection(value: object) -> dict | None: for key in ("member_keys", "selected_member_keys"): if key in value: items = value.get(key) - if not isinstance(items, list): - return None - projected[key] = _bounded_strings(items) + # Keep invalid-present distinct from absent without persisting its + # arbitrary payload or enabling legacy/nested binding fallback. + projected[key] = _bounded_strings(items) if isinstance(items, list) else None return projected diff --git a/docs/attention-and-workroom/architecture.md b/docs/attention-and-workroom/architecture.md index e258f35..9511c53 100644 --- a/docs/attention-and-workroom/architecture.md +++ b/docs/attention-and-workroom/architecture.md @@ -50,7 +50,7 @@ Changing only wrapper text does not create new material. A genuine `revision-b` **Index progress.** The scanner advances through multiple complete JSONL records within the aggregate source-byte budget, stopping at an incomplete tail and retaining cursor/fingerprint/lock ownership. It retains record-size, SQLite timeout and dependent-refresh bounds. Measured VM steps are not a new hard execution ceiling; output limits are not permission to delete old unresolved material. -**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 6. Earlier ready caches cannot certify per-event revision joins or disposition attribution and are rebuilt from canonical JSONL records. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. +**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 7. Earlier ready caches, including generation 6, cannot certify invalid-present member evidence and are refused until bounded prepare rebuilds them from canonical JSONL records. Malformed present member fields remain invalid rather than becoming absent legacy authority; a malformed top-level binding is not replaced by a valid nested binding. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. **Optional latent context.** A direct read-only call can request held/dormant metadata: diff --git a/docs/attention-and-workroom/change-scope.md b/docs/attention-and-workroom/change-scope.md index f6f4d7a..8404100 100644 --- a/docs/attention-and-workroom/change-scope.md +++ b/docs/attention-and-workroom/change-scope.md @@ -9,7 +9,7 @@ This candidate implements three attention changes and documents a native/workroo | Feature group | Status | Behavior, reason and boundary | |---|---|---| | Producer item/revision | **Implemented; repair review pending** | Complete unique per-event source joins establish material equality. Missing revision stays unversioned; conflicting aliases/mixed joins refuse equality. Raw producer payloads cannot impersonate derived cached claims. Equivalent-visibility replay is domain-write/observer-free; stricter replay narrows future lineage visibility without pressure/observer effects. Changed same-item material can coalesce. No thematic equality or manufactured old revision. | -| Bounded index cursor | **Implemented; repair review pending** | Multiple complete records advance within aggregate source-byte budget, retaining validated boundaries when an unchanged offset exhausts the budget; partial tail waits. Cache generation 6 rebuilds obsolete uniqueness/join/disposition semantics from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | +| Bounded index cursor | **Implemented; repair review pending** | Multiple complete records advance within aggregate source-byte budget, retaining validated boundaries when an unchanged offset exhausts the budget; partial tail waits. Cache generation 7 refuses obsolete ready caches, including generation 6, and bounded prepare rebuilds uniqueness/join/disposition and invalid-present member semantics from canonical records without rewriting history. No newest-N replacement or hard VM-step ceiling claim. | | Latent thread awareness | **Implemented; optional; source-reviewed** | Read-only held/dormant metadata, default off, closed visibility/duplicate rejection and honest provenance/unknowns. Six threads/two receipts/6,144-byte output; unbounded input scans, incomplete semantics. No automatic native consumer/resume. | | Native runtime contract | **Proposed; host capability held** | Immutable external approval for selection/generation/interpreter/ABI/import/distribution/route origins; drift HOLD, no automatic rebinding or fallback. Actual host verification not supplied. | | Native session continuity | **Proposed; native capability held** | Stable native root and complete owner/compression/model-history recovery before transcript access. No title adoption, silent fork, wake-summary replacement or reseed. Exact ID/DB/history adapter unproven. | diff --git a/docs/attention-and-workroom/migration.md b/docs/attention-and-workroom/migration.md index 207ef52..89bff8f 100644 --- a/docs/attention-and-workroom/migration.md +++ b/docs/attention-and-workroom/migration.md @@ -6,7 +6,7 @@ Attention can be updated without activating a workroom. No real accounting migra Keep canonical instance/store identities and signal/event/candidate/thread/decision/artifact/outbox ownership. Do not transplant alternate whole trees, deployment fixtures or obsolete assertions over public-only repairs. Producer revisions are not manufactured for old rows. Index progress retains cursors, fingerprints, stream boundaries and old unresolved material; no reset, newest-N deletion or disguised lifetime rescan. -The attention cache is disposable, not canonical accounting. Generation 6 invalidates older ready projections because entity uniqueness, per-event revision validation and disposition attribution changed. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. +The attention cache is disposable, not canonical accounting. Generation 7 invalidates older ready projections, including generation 6 caches that lost invalid-present member evidence and incorrectly acquired unbound legacy settlement authority. Read paths refuse predecessor readiness; bounded prepare rebuilds from canonical JSONLs under the existing scan and dependency-refresh limits. Present malformed member fields remain distinct from absent historical fields, and invalid top-level bindings cannot be replaced by valid nested bindings. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. Exact consumer/aperture generation/lease/presentation ownership remains required. Lease expiry releases execution, not semantic settlement. Future HELD checkpoints and STOP persist. Keep session-local pointers, closed privacy policy, dashboard/URL validation and honest unknown status. Optional implemented latent projection remains read-only, default-off and honestly incomplete; output caps do not bound input I/O. diff --git a/tests/test_canonical_owner_foundation.py b/tests/test_canonical_owner_foundation.py index 9858a02..51d4381 100644 --- a/tests/test_canonical_owner_foundation.py +++ b/tests/test_canonical_owner_foundation.py @@ -474,3 +474,107 @@ def test_legacy_exact_fallback_rejects_present_members_but_keeps_absent_inverse( assert bool(raw[0]) is valid assert raw[2] == ("legacy_exact_source_disposition" if valid else None) assert cacheless["selection"] == indexed["selection"] == (None if valid else binding) + + +@pytest.mark.parametrize('field', ['member_keys','selected_member_keys']) +@pytest.mark.parametrize('source,nested,invalid', [(False,False,None),(False,True,3),(False,False,{}),(False,True,['a'*64,3]),(True,False,None),(True,True,3),(True,False,{}),(True,True,['a'*64,3])]) +def test_present_member_shape_before_every_authority(tmp_path, field, source, nested, invalid): + store=foundation_seed(tmp_path,source=source) + binding,error=admission.binding_for_candidate(store,'c') + assert error is None + prior={**binding,field:invalid} + row={'id':'adv','kind':'subconscious_advisory','status':'archived','source_candidate_ids':['c'],'source_candidate_fingerprint':binding['source_candidate_fingerprint']} + if nested: + row['advisory_meta']={'admission_binding':prior} + else: + row['admission_binding']=prior + store.append_jsonl('candidates',row) + before=canonical(store) + raw=admission._prior_for_binding(store,binding,raw_snapshot(store)) + cacheless=admission.build_admission_plan(store) + foundation_finish(store) + indexed=admission.build_admission_plan(store) + assert canonical(store)==before + print(json.dumps({'probe':'shape','source':source,'nested':nested,'invalid':invalid,'field':field,'raw':raw,'cacheless':cacheless['selection'],'indexed':indexed['selection'],'binding':binding}),flush=True) + assert raw[0]==[] and raw[1] is False and raw[2] is None + assert cacheless['selection']==indexed['selection']==binding + + +@pytest.mark.parametrize("field", ["member_keys", "selected_member_keys"]) +@pytest.mark.parametrize("value,valid", [ + (None, False), (3, False), ({"untrusted": "payload"}, False), + ("untrusted payload", False), (True, False), (["a" * 64, 3], False), + (["b" * 64, "a" * 64], False), (["a" * 64, "a" * 64], False), + ([], True), (["a" * 64, "b" * 64], True), +]) +def test_binding_projection_preserves_member_shape(field, value, valid): + from agent_sensorium.admission_index import _binding_projection, _decision_projection + + raw = {"item_key": "item", field: value} + before = json.dumps(raw, sort_keys=True) + projected = _binding_projection(raw) + assert isinstance(projected, dict) and field in projected + assert projected[field] == (value if isinstance(value, list) else None) + assert admission._member_reference_shape(projected) is valid + assert _decision_projection({"admission_binding": raw})["admission_binding"] == projected + assert json.dumps(raw, sort_keys=True) == before + assert _binding_projection({"item_key": "item"}) == {"item_key": "item"} + + +@pytest.mark.parametrize("source", [False, True]) +@pytest.mark.parametrize("case", ["receipt_bool", "advisory_string", "invalid_top", "absent_top"]) +def test_projection_precedence_and_authority_parity(tmp_path, source, case): + store = foundation_seed(tmp_path, source=source) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and binding is not None + historical = {**binding, "policy_version": "source-admission-v1"} + row = {"id": "adv", "kind": "subconscious_advisory", "status": "archived", + "source_candidate_ids": ["c"], "dry_run": False, "action": "drop", + "output_action": "DROP"} + if case == "receipt_bool": + row["admission_binding"] = {**binding, "selected_member_keys": True} + elif case == "advisory_string": + row["admission_binding"] = {**binding, "member_keys": "untrusted payload"} + else: + row["advisory_meta"] = {"admission_binding": historical} + if case == "invalid_top": + row["admission_binding"] = {**binding, "member_keys": {"untrusted": "payload"}} + store.append_jsonl("decisions" if case == "receipt_bool" else "candidates", row) + before = canonical(store) + raw = admission._prior_for_binding(store, binding, raw_snapshot(store)) + cacheless = admission.build_admission_plan(store) + foundation_finish(store) + indexed = admission.build_admission_plan(store) + valid = case == "absent_top" + assert raw[1] is valid and bool(raw[0]) is valid + assert raw[2] == ("prior_disposition" if valid else None) + assert cacheless["selection"] == indexed["selection"] == (None if valid else binding) + assert canonical(store) == before + + + +def test_predecessor_generation_ready_reads_refuse_until_bounded_rebuild(tmp_path): + from agent_sensorium.admission_index import SCHEMA_VERSION + + store = foundation_seed(tmp_path) + binding, error = admission.binding_for_candidate(store, "c") + assert error is None and binding is not None + foundation_finish(store) + index = AdmissionIndex(store) + with sqlite3.connect(index.path) as conn: + conn.execute("PRAGMA user_version=6") + before = canonical(store) + cache_before = index.path.read_bytes() + for read in [lambda: index.read_plan(candidate_limit=50), + lambda: index.read_binding("c"), lambda: index.read_dispositions(binding), + lambda: index.read_context("c"), index.read_snapshot]: + result = read() + assert not result.complete and result.state == "rebuilding" + assert result.reason == "admission_claim_generation_changed" + assert index.path.read_bytes() == cache_before + first = store.prepare_admission_index(scan_bytes=256) + assert not first.complete and first.bytes_consumed <= 256 + foundation_finish(store) + assert SCHEMA_VERSION == 7 + assert admission.build_admission_plan(store)["selection"] == binding + assert canonical(store) == before From 9171621c96aa0bc899c57e3c18d2edaf8900a147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 08:02:31 +0200 Subject: [PATCH 09/16] Reject canonical ingress wrapper collisions before replay effects --- agent_sensorium/admission.py | 48 ++- agent_sensorium/tools.py | 234 +++++++------ docs/attention-and-workroom/architecture.md | 6 +- docs/attention-and-workroom/change-scope.md | 2 +- .../design-rationale.md | 2 + docs/attention-and-workroom/migration.md | 4 +- tests/test_canonical_ingress_replay.py | 312 ++++++++++++++++++ 7 files changed, 492 insertions(+), 116 deletions(-) create mode 100644 tests/test_canonical_ingress_replay.py diff --git a/agent_sensorium/admission.py b/agent_sensorium/admission.py index 1dafb48..462aa76 100644 --- a/agent_sensorium/admission.py +++ b/agent_sensorium/admission.py @@ -210,6 +210,33 @@ def equivalent_source_claims(left: SignalClaim, right: SignalClaim) -> bool: return left.kind == right.kind == "source" and left == right +def classify_event_claim(instance: str, event: dict, *, signals: list[dict]) -> SignalClaim: + """Classify a complete retained or prospective join without filtering failures. + + Empty references preserve standalone trusted legacy events. Referenced legacy + signals must still resolve uniquely; mixed, partial or invalid joins fail. + Callers may supply a prospective signal alongside retained rows before writes. + """ + refs = event.get("source_signal_ids", []) + if not isinstance(refs, list): + return SignalClaim("malformed", error="invalid_event_join") + index = record_index(signals) + claims = [] + for identifier in refs: + found = lookup_record(index, identifier) + if found.state != "unique" or found.row is None: + return SignalClaim("malformed", error="invalid_event_join") + claim = classify_signal_claim(instance, found.row) + if claim.kind == "malformed": + return SignalClaim("malformed", error="invalid_event_join") + claims.append(claim) + if not claims or all(claim.kind == "legacy" for claim in claims): + return SignalClaim("legacy") + if all(equivalent_source_claims(claims[0], claim) for claim in claims): + return claims[0] + return SignalClaim("malformed", error="invalid_event_join") + + class _UniqueSnapshot(dict): """Indexes owned by one immutable admission view, never serialized trust.""" @@ -865,22 +892,11 @@ def event_source_item_identity( ) -> tuple[str, str] | None: """Return one unambiguous item/revision claim joined through event signals.""" rows = signals if signals is not None else store.read_jsonl("signals") - signal_index = record_index(rows) - signal_ids = event.get("source_signal_ids") or [] - if not isinstance(signal_ids, list) or not signal_ids or any( - lookup_record(signal_index, identifier).state != "unique" for identifier in signal_ids - ): - return None - identities: set[tuple[str, str]] = set() - for signal_id in signal_ids: - signal = lookup_record(signal_index, signal_id).row - if not isinstance(signal, dict): - return None - identity = signal_source_item_identity(store.instance, signal) - if identity is None: - return None - identities.add(identity) - return next(iter(identities)) if len(identities) == 1 else None + claim = classify_event_claim(store.instance, event, signals=rows) + if (claim.kind == "source" and claim.item_key is not None and claim.revision_key is not None + and claim.evidence_class in {"source_owned", "internal_interpretation"}): + return claim.item_key, claim.revision_key + return None def event_source_item_key( diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index e50bbe6..4fcfff3 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -7,6 +7,8 @@ from pathlib import Path from .attention import build_attention_inbox +from .admission import classify_event_claim, classify_signal_claim, equivalent_source_claims +from .canonical_records import lookup_record, record_index from .dispatcher import current_budget_state, dispatch_once as _dispatch_once from .gate import ( build_pressure_pitch, @@ -374,48 +376,55 @@ def _stored_candidate_fingerprint(candidate: dict) -> str: def _candidate_for_event_id(candidates: list[dict], event_id: str) -> dict | None: - for candidate in candidates: - if event_id in (candidate.get("event_ids") or []): - return candidate - return None + return _unique_match(candidates, [row for row in candidates + if event_id in (row.get("event_ids") or [])]) def _candidate_for_event_fingerprint(candidates: list[dict], event: dict) -> dict | None: event_candidate = event_to_candidate(event) event_candidate_fp = event_candidate.get("fingerprint") or candidate_fingerprint(event_candidate) - for candidate in candidates: - if _stored_candidate_fingerprint(candidate) == event_candidate_fp: - return candidate - return None - - -def _find_existing_signal(signals: list[dict], normalized: dict) -> dict | None: - incoming_fp = normalized.get("fingerprint") or signal_fingerprint(normalized) - incoming_id = normalized.get("id") - for existing in signals: - if incoming_id and existing.get("id") == incoming_id: - return existing - if _stored_signal_fingerprint(existing) == incoming_fp: - return existing - return None - + return _unique_match(candidates, [row for row in candidates + if _stored_candidate_fingerprint(row) == event_candidate_fp]) + + +def _unique_match(rows: list[dict], matches: list[dict]) -> dict | None: + """Never select a first/last entity or a duplicate literal identifier.""" + index = record_index(rows) + if len(matches) > 1 or any(lookup_record(index, row.get("id")).state != "unique" + for row in matches): + raise ValueError("ambiguous_ingress_lineage") + return matches[0] if matches else None + + +def _canonical_replays(rows, incoming, *, classify, fingerprint): + """Canonical equality owns replay; fingerprints only compare legacy views.""" + claim = classify(incoming) + if claim.kind == "malformed": + raise ValueError("invalid_ingress_claim") + index = record_index(rows) + found = lookup_record(index, incoming.get("id")) + if found.state == "ambiguous": + raise ValueError("ambiguous_ingress_wrapper") + + def equivalent(row): + prior = classify(row) + return equivalent_source_claims(claim, prior) or ( + claim.kind == prior.kind == "legacy" and fingerprint(row) == fingerprint(incoming) + ) -def _find_existing_event(events: list[dict], incoming: dict) -> dict | None: - incoming_fp = incoming.get("fingerprint") or event_fingerprint(incoming) - incoming_id = incoming.get("id") - for existing in events: - if incoming_id and existing.get("id") == incoming_id: - return existing - if _stored_event_fingerprint(existing) == incoming_fp: - return existing - return None + if found.row is not None and not equivalent(found.row): + raise ValueError("ingress_wrapper_collision") + matches = [row for row in rows if equivalent(row)] + if any(lookup_record(index, row.get("id")).state != "unique" for row in matches): + raise ValueError("ambiguous_ingress_wrapper") + if len(matches) > 1: + raise ValueError("ambiguous_ingress_material") + return matches def _find_event_for_signal(events: list[dict], signal_id: str) -> dict | None: - for event in events: - if signal_id in (event.get("source_signal_ids") or []): - return event - return None + return _unique_match(events, [row for row in events + if signal_id in (row.get("source_signal_ids") or [])]) GENERIC_CORRELATION_KEYS = {"active-session"} @@ -453,14 +462,18 @@ def _find_related_candidate( if incoming_identity is None: return None incoming_item = incoming_identity[0] - event_index = {row.get("id"): row for row in events} + event_index = record_index(events) + candidate_index = record_index(candidates) + matches = [] for candidate in candidates: + if lookup_record(candidate_index, candidate.get("id")).state != "unique": + continue if candidate.get("status", "candidate") != "candidate": continue if candidate.get("kind") != event.get("kind"): continue candidate_event_ids = list(candidate.get("event_ids") or []) - prior_events = [event_index.get(event_id) for event_id in candidate_event_ids] + prior_events = [lookup_record(event_index, event_id).row for event_id in candidate_event_ids] if not prior_events or any(not isinstance(prior_event, dict) for prior_event in prior_events): continue prior_identities = [ @@ -470,8 +483,8 @@ def _find_related_candidate( if any(identity is None for identity in prior_identities): continue if {identity[0] for identity in prior_identities if identity is not None} == {incoming_item}: - return candidate - return None + matches.append(candidate) + return _unique_match(candidates, matches) def _read_pruned_sensor_policy( @@ -519,10 +532,7 @@ def _apply_candidate_decay( def _event_to_existing_result(event: dict, candidates: list[dict]) -> dict: - candidate = ( - _candidate_for_event_id(candidates, event.get("id", "")) - or _candidate_for_event_fingerprint(candidates, event) - ) + candidate = _candidate_for_event_id(candidates, event.get("id", "")) result = { "event_id": event.get("id"), "duplicate": True, @@ -550,7 +560,7 @@ def _promoted_signal_existing_result(signal: dict, events: list[dict], candidate @_candidate_transactional def _narrow_replay_visibility( - store: SensoriumStore, incoming: dict, *, signal_ids=(), event_ids=(), + store: SensoriumStore, incoming: dict, *, signal_ids=(), event_ids=(), check_only=False, ) -> None: """Restrict retained lineage for future disclosure, never replay its effects. @@ -562,6 +572,39 @@ def _narrow_replay_visibility( candidates = store.read_jsonl("candidates") signal_ids, event_ids = set(signal_ids), set(event_ids) + # Validate the entire reachable lineage before mutating even in-memory rows. + signal_index, event_index = record_index(signals), record_index(events) + candidate_index = record_index(candidates) + for identifier in signal_ids: + if lookup_record(signal_index, identifier).state != "unique": + raise ValueError("ambiguous_ingress_lineage") + for row in events: + if row.get("id") in event_ids or signal_ids.intersection(row.get("source_signal_ids") or []): + if (lookup_record(event_index, row.get("id")).state != "unique" + or classify_event_claim(store.instance, row, signals=signals).kind == "malformed"): + raise ValueError("ambiguous_ingress_lineage") + event_ids.add(row.get("id")) + affected = {row.get("id") for row in candidates + if event_ids.intersection(row.get("event_ids") or [])} + while True: + descendants = {row.get("id") for row in candidates + if affected.intersection(row.get("source_candidate_ids") or [])} + if descendants.issubset(affected): + break + affected.update(descendants) + for identifier in affected: + row = lookup_record(candidate_index, identifier).row + if row is None or any(lookup_record(event_index, ref).state != "unique" + for ref in row.get("event_ids") or []): + raise ValueError("ambiguous_ingress_lineage") + threads = store.read_jsonl("threads") + thread_index = record_index(threads) + if any(lookup_record(thread_index, row.get("id")).state != "unique" + for row in threads if row.get("origin_candidate_id") in affected): + raise ValueError("ambiguous_ingress_lineage") + if check_only: + return + def narrow(row): sensitivity = merge_sensitivity([ row.get("sensitivity", "private"), incoming.get("sensitivity", "private"), @@ -670,29 +713,18 @@ def _append_event_and_create_or_update_candidate( signals = store.read_jsonl("signals") candidates = store.read_jsonl("candidates") - existing_event = _find_existing_event(events, event) - from .admission import event_source_item_identity - - if existing_event is None: - incoming_identity = event_source_item_identity(store, event, signals=signals) - if incoming_identity is not None: - existing_event = next((row for row in events - if event_source_item_identity(store, row, signals=signals) == incoming_identity), None) - if existing_event: - retained_identity = event_source_item_identity(store, existing_event, signals=signals) - replay_ids = [existing_event["id"]] - if retained_identity is not None: - replay_ids = [row["id"] for row in events - if event_source_item_identity(store, row, signals=signals) == retained_identity] - _narrow_replay_visibility(store, event, event_ids=replay_ids) - return _event_to_existing_result(existing_event, candidates) + replays, related = _preflight_event(store, event, events=events, signals=signals, + candidates=candidates) + if replays: + result = _event_to_existing_result(replays[0], candidates) + _narrow_replay_visibility(store, event, event_ids=[row["id"] for row in replays]) + return result _apply_candidate_decay(store, candidates, config=config) store.append_jsonl("events", event) candidate = event_to_candidate(event, config) - related = _find_related_candidate( - store, candidates, event, events=events, signals=signals, - ) + if related: + related = lookup_record(record_index(candidates), related["id"]).row if related: receipt = _coalesce_candidate_with_event( store, @@ -718,6 +750,22 @@ def _append_event_and_create_or_update_candidate( } +def _preflight_event(store, event, *, events, signals, candidates): + replays = _canonical_replays( + events, event, + classify=lambda row: classify_event_claim(store.instance, row, signals=signals), + fingerprint=event_fingerprint, + ) + if replays: + for row in replays: + _event_to_existing_result(row, candidates) + _narrow_replay_visibility(store, event, event_ids=[row["id"] for row in replays], + check_only=True) + return replays, None + related = _find_related_candidate(store, candidates, event, events=events, signals=signals) + return [], related + + def handle_sensorium_sensor_config( *, action: str, @@ -877,35 +925,35 @@ def handle_sensorium_ingest_signal( if config is None: config, _ = load_instance_config(state_dir=str(store.root)) + if classify_signal_claim(instance, signal).kind == "malformed": + return _err(instance, "invalid_ingress_claim") normalized = normalize_signal(signal) - normalized["fingerprint"] = signal_fingerprint(normalized) - signals = store.read_jsonl("signals") - existing_signal = _find_existing_signal(signals, normalized) - if existing_signal is None: - from .admission import signal_source_item_identity - - incoming_identity = signal_source_item_identity(instance, normalized) - if incoming_identity is not None: - existing_signal = next( - ( - prior for prior in signals - if signal_source_item_identity(instance, prior) == incoming_identity - ), - None, - ) - if existing_signal: - from .admission import signal_source_item_identity - - retained_identity = signal_source_item_identity(instance, existing_signal) - replay_ids = [existing_signal["id"]] - if retained_identity is not None: - replay_ids = [row["id"] for row in signals - if signal_source_item_identity(instance, row) == retained_identity] - _narrow_replay_visibility(store, normalized, signal_ids=replay_ids) + try: + replays = _canonical_replays(signals, normalized, + classify=lambda row: classify_signal_claim(instance, row), + fingerprint=signal_fingerprint) events = store.read_jsonl("events") candidates = store.read_jsonl("candidates") - return _ok(instance, _promoted_signal_existing_result(existing_signal, events, candidates)) + if replays: + results = [_promoted_signal_existing_result(row, events, candidates) for row in replays] + _narrow_replay_visibility(store, normalized, signal_ids=[row["id"] for row in replays]) + return _ok(instance, results[0]) + # Read/prune without persistence until downstream admission is proven. + policy = prune_sensor_policy(store.read_sensor_policy(), config=config) + inhibited, inhibition_reason = inhibited_by_sensor_policy(normalized, policy, config=config) + promoted, reason = should_promote_signal(normalized, config) + if not promoted: + promoted, supported_reason = should_promote_supported_signal(normalized, signals, config) + if promoted: + reason = supported_reason + event = promote_signal_to_event(normalized, config) if promoted and not inhibited else None + if event is not None: + _preflight_event(store, event, events=events, signals=[*signals, normalized], + candidates=candidates) + except ValueError as exc: + return _err(instance, str(exc)) + normalized["fingerprint"] = signal_fingerprint(normalized) policy = _read_pruned_sensor_policy(store, config=config) inhibited, inhibition_reason = inhibited_by_sensor_policy(normalized, policy, config=config) @@ -934,11 +982,6 @@ def handle_sensorium_ingest_signal( "reason": f"inhibited: {inhibition_reason}", }) - promoted, reason = should_promote_signal(normalized, config) - if not promoted: - promoted, supported_reason = should_promote_supported_signal(normalized, signals, config) - if promoted: - reason = supported_reason result: dict = { "signal_id": normalized["id"], "promoted": promoted, @@ -947,7 +990,7 @@ def handle_sensorium_ingest_signal( } if promoted: - event = promote_signal_to_event(normalized, config) + assert event is not None event_result = _append_event_and_create_or_update_candidate(store, event=event, config=config) # Detached best-effort observer runs only after the canonical owner commits. observe_after_success(store.root, config.get("prospective_evidence_capture", {}), "candidate_updated", { @@ -1002,14 +1045,15 @@ def handle_sensorium_ingest_event( incoming.setdefault("sensitivity", "private") incoming.setdefault("allowed_surfaces", ["local"]) incoming.setdefault("expires_at", "") - incoming["fingerprint"] = event_fingerprint(incoming) - try: validate_event(incoming) + if classify_event_claim(instance, incoming, signals=store.read_jsonl("signals")).kind == "malformed": + return _err(instance, "invalid_ingress_claim") + incoming["fingerprint"] = event_fingerprint(incoming) + result = _append_event_and_create_or_update_candidate(store, event=incoming, config=config) except ValueError as e: return _err(instance, str(e)) - result = _append_event_and_create_or_update_candidate(store, event=incoming, config=config) if not result.get("duplicate"): observe_after_success(store.root, config.get("prospective_evidence_capture", {}), "candidate_updated", { "candidate_id": result.get("candidate_id", ""), "event_id": result.get("event_id", ""), diff --git a/docs/attention-and-workroom/architecture.md b/docs/attention-and-workroom/architecture.md index 9511c53..299160e 100644 --- a/docs/attention-and-workroom/architecture.md +++ b/docs/attention-and-workroom/architecture.md @@ -44,13 +44,15 @@ Synthetic producer fragment (not a live-tool payload): } ``` -Changing only wrapper text does not create new material. A genuine `revision-b` can update the same item; changing the item is not equality merely because correlation keys match. An advisory synthesis of several items must retain their evidence references and uncertainty; it cannot assign a shared material identity by paraphrase. +Changing only wrapper text does not create new material. A genuine `revision-b` with a fresh wrapper can update the same item; changing the item is not equality merely because correlation keys match. A literal wrapper denotes one unique retained entity: changed item, revision, memory members, evidence class or source/legacy classification is a collision, not replay. Malformed-present claims and ambiguous retained wrappers reject before domain writes, narrowing, decay or detached observations. Legacy replay requires representation equality; producer replay requires full canonical claim equality regardless of representation fingerprints. Multiple equivalent retained wrappers cannot become a first/last replay target. An advisory synthesis of several items must retain their evidence references and uncertainty; it cannot assign a shared material identity by paraphrase. + +Both ingress handlers and the internal event append owner use canonical classification. Trusted events resolve complete uniquely retained signal references through the shared typed join; the ordinary signal route checks its prospective event before appending the signal. Reachable replay descendants must be uniquely bound before any narrowing. Supported unique producer item ownership permits successive-revision coalescence, but typed telemetry claims do not acquire that permission. This is preflight under existing owners, not rollback or a new authority protocol. **Derived identity trust.** Public raw producer records cannot supply trusted `_admission_claim` or `_admission_error` values. Ingress, raw event joins and index construction derive identity from canonical producer fields. Only structurally owned internal snapshots consume cached projections; serializing a snapshot back to JSON does not preserve that trust. **Index progress.** The scanner advances through multiple complete JSONL records within the aggregate source-byte budget, stopping at an incomplete tail and retaining cursor/fingerprint/lock ownership. It retains record-size, SQLite timeout and dependent-refresh bounds. Measured VM steps are not a new hard execution ceiling; output limits are not permission to delete old unresolved material. -**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 7. Earlier ready caches, including generation 6, cannot certify invalid-present member evidence and are refused until bounded prepare rebuilds them from canonical JSONL records. Malformed present member fields remain invalid rather than becoming absent legacy authority; a malformed top-level binding is not replaced by a valid nested binding. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. +**Disposable cache compatibility.** Code and persistent SQL schema use cache generation 7. Earlier ready caches, including generation 6, cannot certify invalid-present member evidence and are refused until bounded prepare rebuilds them from canonical JSONL records. Malformed present member fields remain invalid rather than becoming absent legacy authority; malformed PRESENT member fields in a top-level dict binding are not replaced by a valid nested binding. This states member-field precedence, not a policy for all malformed top-level binding types. Rebuild preserves genuine unversioned settlement and canonical history/accounting. An old unversioned disposition does not prove settlement of an explicit revision, even with unchanged retained IDs/representation; see [migration](migration.md). Cache readiness is not proof that its derived semantics are current. **Optional latent context.** A direct read-only call can request held/dormant metadata: diff --git a/docs/attention-and-workroom/change-scope.md b/docs/attention-and-workroom/change-scope.md index 8404100..85fb9f2 100644 --- a/docs/attention-and-workroom/change-scope.md +++ b/docs/attention-and-workroom/change-scope.md @@ -27,7 +27,7 @@ This candidate implements three attention changes and documents a native/workroo ## Retained protections -The canonical admission foundation rejects ambiguous candidate/advisory authority, preserves malformed joins without projection filtering and constructs uniqueness indexes once per immutable snapshot. Unique uncertain candidates may retain legacy first consideration, never a fresh producer revision. Existing quadratic prior scans and measured SQL work are not a new hard execution budget. Tools ingress (B) and lineage visibility (C) remain future-gated and unchanged. +The canonical admission foundation rejects ambiguous candidate/advisory authority, preserves malformed joins without projection filtering and constructs uniqueness indexes once per immutable snapshot. Unique uncertain candidates may retain legacy first consideration, never a fresh producer revision. Existing quadratic prior scans and measured SQL work are not a new hard execution budget. Tools ingress (B) now classifies canonical claims and complete typed event joins before replay or effects, rejecting wrapper collisions and ambiguous replay descendants. Its local implementation requires independent acceptance; lineage visibility policy (C) remains held and unchanged. No cache/schema, fingerprint protocol or observer contract is changed by B. - **Instance/config/store/live/admin:** canonical instance validation before effects, no live arbitrary state-root injection, small ordinary tool surface, explicit admin and generic configurable profiles. - **Admission/current attention:** source authority and anti-paraphrase suppression, unresolved/held eligibility, bounded attempts and exact index/completeness owners; no capacity-burn projection/window remapping restored. diff --git a/docs/attention-and-workroom/design-rationale.md b/docs/attention-and-workroom/design-rationale.md index 56b5a2d..bc04120 100644 --- a/docs/attention-and-workroom/design-rationale.md +++ b/docs/attention-and-workroom/design-rationale.md @@ -8,6 +8,8 @@ Correlation is a thematic relationship, not equality. Complete source-owned item Multiple complete records per bounded index pass improve progress without replacing cursor/fingerprint/lock authority. A partial tail waits for completion. Neither newest-N snapshots nor time expiry preserve old unresolved material. +Representation fingerprints omit some canonical evidence (notably memory members), so they cannot authorize source replay. Ingress reuses the canonical signal classifier and record-local uniqueness owner, with one typed prospective/retained event-join API. Its legacy/source/malformed result distinguishes a genuinely unversioned trusted event from failed joins; the compatibility identity projection still returns no item for telemetry or legacy. Literal wrapper collisions and ambiguous replay lineages fail before writes or observers. Fresh material needs a fresh wrapper, not a replacement append under an old ID. Preflighting the ordinary signal's downstream event avoids a partial signal append without introducing rollback machinery. Equal legacy representations and complete equivalent producer material remain valid replay inverses. + ## Latent context is not a new foreground job An explicitly selected review may benefit from held/dormant context, but projection must not launch a review or write lifecycle state. Explicit hold provenance differs from recorded status; operator evaluation differs from preference; ledger touch differs from source observation time. Unknown remains unknown. Output caps are real, but full input reads are unbounded and the projection reports incomplete semantics. No automatic native consumer is included. diff --git a/docs/attention-and-workroom/migration.md b/docs/attention-and-workroom/migration.md index 89bff8f..c12c32a 100644 --- a/docs/attention-and-workroom/migration.md +++ b/docs/attention-and-workroom/migration.md @@ -6,13 +6,13 @@ Attention can be updated without activating a workroom. No real accounting migra Keep canonical instance/store identities and signal/event/candidate/thread/decision/artifact/outbox ownership. Do not transplant alternate whole trees, deployment fixtures or obsolete assertions over public-only repairs. Producer revisions are not manufactured for old rows. Index progress retains cursors, fingerprints, stream boundaries and old unresolved material; no reset, newest-N deletion or disguised lifetime rescan. -The attention cache is disposable, not canonical accounting. Generation 7 invalidates older ready projections, including generation 6 caches that lost invalid-present member evidence and incorrectly acquired unbound legacy settlement authority. Read paths refuse predecessor readiness; bounded prepare rebuilds from canonical JSONLs under the existing scan and dependency-refresh limits. Present malformed member fields remain distinct from absent historical fields, and invalid top-level bindings cannot be replaced by valid nested bindings. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. +The attention cache is disposable, not canonical accounting. Generation 7 invalidates older ready projections, including generation 6 caches that lost invalid-present member evidence and incorrectly acquired unbound legacy settlement authority. Read paths refuse predecessor readiness; bounded prepare rebuilds from canonical JSONLs under the existing scan and dependency-refresh limits. Present malformed member fields remain distinct from absent historical fields, and malformed PRESENT member fields in top-level dict bindings cannot be replaced by valid nested bindings. This does not establish a new policy for other top-level binding types. Rebuild derives claims from canonical records, ignoring producer-supplied reserved cache fields, without rewriting source history, settlements or debit. Genuine base-era unversioned material stays settled by its exact key. An old unversioned disposition cannot establish settlement of an explicit producer revision: retained IDs and candidate representation are not historical revision proof, including after rewrite/compaction. Decisions and debit remain retained even when that explicit revision is eligible for consideration. This derived-cache rebuild is implemented and does not authorize the proposed workroom-accounting migration below. Exact consumer/aperture generation/lease/presentation ownership remains required. Lease expiry releases execution, not semantic settlement. Future HELD checkpoints and STOP persist. Keep session-local pointers, closed privacy policy, dashboard/URL validation and honest unknown status. Optional implemented latent projection remains read-only, default-off and honestly incomplete; output caps do not bound input I/O. ## Native adoption is proposed, never implicit -Duplicate entity IDs remain ambiguous until canonical rewrite restores uniqueness; append replacement IDs are not a supported revision contract. Rebuild and dependency refresh withdraw ambiguous advisory settlement without rewriting canonical history. Missing or malformed joins of a unique candidate remain uncertain legacy where policy permits, not source equality. Fixture completion of a missing distinct event is legacy-to-source; a valid source-to-source transition instead rewrites the unique canonical event or updates candidate references to a fresh distinct event. Tools ingress (B) and visibility (C) need separate acceptance. +Duplicate entity IDs remain ambiguous until canonical rewrite restores uniqueness; append replacement IDs are not a supported revision contract. Rebuild and dependency refresh withdraw ambiguous advisory settlement without rewriting canonical history. Missing or malformed joins of a unique candidate remain uncertain legacy where policy permits, not source equality. Fixture completion of a missing distinct event is legacy-to-source; a valid source-to-source transition instead rewrites the unique canonical event or updates candidate references to a fresh distinct event. Ingress rejects malformed claims, incomplete trusted-event joins and non-equivalent wrapper reuse without domain or observer effects. Producers must supply a fresh wrapper for changed material; synthetic fixtures must do the same rather than weakening collision assertions. Valid equal legacy replay and canonical producer replay remain supported. B requires independent acceptance; visibility policy (C) remains separately held. A future generic extension must import cold without private files/dependencies, path discovery, registry mutation, provider initialization or inference. No embedded deployment root, endpoint, memory target, grant or ledger path. Immutable externally approved context pins runtime/package-manager generation/interpreter/ABI/import origins, selected route/model/configuration, canonical DB/profile/tier/root, artifact owner, memory read owner and source authorization. Current observations must match; drift HOLDs. Code upgrades or relocation require fresh approval, never copied bindings or automatic digest refresh. Accounting authority persists through route/runtime changes. diff --git a/tests/test_canonical_ingress_replay.py b/tests/test_canonical_ingress_replay.py new file mode 100644 index 0000000..8313433 --- /dev/null +++ b/tests/test_canonical_ingress_replay.py @@ -0,0 +1,312 @@ +"""Fictional production-path replay collisions and valid inverse controls.""" +import copy +import json + +import pytest + +from agent_sensorium import admission, tools +from agent_sensorium.gate import promote_signal_to_event, signal_fingerprint +from tests.test_core_material_replay import ingest, signal, snapshot, store_at + + +def call(store, row, event=False): + handler = tools.handle_sensorium_ingest_event if event else tools.handle_sensorium_ingest_signal + return json.loads(handler(**{"event" if event else "signal": row}, instance=store.instance, + state_dir=str(store.root), config={})) + + +def forbid_effects(store, monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("Rejected ingress must not write, decay, prune or observe") + monkeypatch.setattr(tools, "observe_after_success", forbidden) + monkeypatch.setattr(tools, "_apply_candidate_decay", forbidden) + monkeypatch.setattr(tools, "_read_pruned_sensor_policy", forbidden) + monkeypatch.setattr(type(store), "append_jsonl", forbidden) + monkeypatch.setattr(type(store), "rewrite_jsonl", forbidden) + monkeypatch.setattr(type(store), "write_state", forbidden) + monkeypatch.setattr(type(store), "write_sensor_policy", forbidden) + + +# Ported original B RED cases, preserving their collision and memory oracles. +@pytest.mark.parametrize("change", ["revision", "item", "legacy_to_explicit", "memory_member", + "malformed_revision", "source_to_legacy", "alias", "evidence"]) +def test_signal_wrapper_collision_is_not_replay(tmp_path, monkeypatch, change): + store = store_at(tmp_path) + first = signal("same") + first.update(sensitivity="public_safe", allowed_surfaces=["local", "telegram"]) + incoming = signal("same", "r2") + if change == "item": + incoming = signal("same", "r1", "b") + elif change == "legacy_to_explicit": + first.pop("artifact_meta") + first.pop("source_revision") + elif change == "memory_member": + first.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider": "fiction", "bank_id": "bank", "item_ids": ["a"]}) + first.pop("artifact_meta") + first.pop("source_revision") + incoming = {**first, "memory_provenance": {"provider": "fiction", "bank_id": "bank", "item_ids": ["b"]}} + elif change == "malformed_revision": + incoming["source_revision"] = None + elif change == "source_to_legacy": + incoming.pop("artifact_meta") + incoming.pop("source_revision") + elif change == "alias": + incoming["artifact_meta"]["revision_id"] = "r1" + elif change == "evidence": + incoming.update(sensor="body.synthetic", source="telemetry", kind="body_pressure", + source_revision="1", metric_family="fiction") + incoming.pop("artifact_meta") + ingest(store, first) + before = snapshot(store) + incoming.update(sensitivity="local_only", allowed_surfaces=[]) + forbid_effects(store, monkeypatch) + assert not call(store, incoming)["success"] + assert snapshot(store) == before + + +def test_event_wrapper_collision_is_not_replay(tmp_path, monkeypatch): + store = store_at(tmp_path) + ingest(store, signal()) + event = dict(store.read_jsonl("events")[0]) + store.append_jsonl("signals", signal("sig-b", "r2")) + event.update(source_signal_ids=["sig-b"], allowed_surfaces=[], sensitivity="local_only") + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, event, event=True)["success"] + with pytest.raises(ValueError, match="collision"): + tools._append_event_and_create_or_update_candidate(store, event=event, config={}) + assert snapshot(store) == before + + +def test_memory_fingerprint_does_not_override_member_identity(tmp_path): + store = store_at(tmp_path) + first = signal("m1", None) + first.pop("artifact_meta") + first.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider": "fiction", "bank_id": "bank", "item_ids": ["a"]}) + incoming = {**first, "id": "m2", "memory_provenance": {"provider": "fiction", "bank_id": "bank", "item_ids": ["b"]}} + assert signal_fingerprint(first) == signal_fingerprint(incoming) + ingest(store, first) + result = ingest(store, incoming) + assert not result["duplicate"] and len(store.read_jsonl("signals")) == 2 + + +@pytest.mark.parametrize("stream", ["signals", "events", "candidates", "threads"]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_replay_rejects_ambiguous_descendants_without_narrowing(tmp_path, monkeypatch, stream, reverse): + store = store_at(tmp_path) + ingest(store, signal()) + if stream == "threads": + candidate = store.read_jsonl("candidates")[0] + store.append_jsonl("threads", {"id": "thread-fiction", "origin_candidate_id": candidate["id"]}) + rows = store.read_jsonl(stream) + duplicate = {**rows[0], "summary": "Other fictional wrapper"} + rows = [duplicate, rows[0]] if reverse else [rows[0], duplicate] + store.rewrite_jsonl(stream, rows) + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, {**signal(), "sensitivity": "local_only"})["success"] + assert snapshot(store) == before + + +@pytest.mark.parametrize("shape", ["missing", "ambiguous", "malformed", "mixed_legacy", "mixed_revision", "bad_refs"]) +def test_trusted_join_rejected_before_wrapper_replay(tmp_path, monkeypatch, shape): + store = store_at(tmp_path) + ingest(store, signal()) + event = dict(store.read_jsonl("events")[0]) + extra = signal("extra", "r2") + if shape == "missing": + event["source_signal_ids"].append("absent") + elif shape == "ambiguous": + store.append_jsonl("signals", signal()) + elif shape == "malformed": + rows = store.read_jsonl("signals") + rows[0]["source_revision"] = None + store.rewrite_jsonl("signals", rows) + elif shape in {"mixed_legacy", "mixed_revision"}: + if shape == "mixed_legacy": + extra.pop("artifact_meta") + extra.pop("source_revision") + store.append_jsonl("signals", extra) + event["source_signal_ids"].append("extra") + else: + event["source_signal_ids"] = "sig-a" + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, event, event=True)["success"] + with pytest.raises(ValueError): + tools._append_event_and_create_or_update_candidate(store, event=event, config={}) + assert snapshot(store) == before + + +def test_ordinary_signal_preflights_event_collision_before_signal_write(tmp_path, monkeypatch): + store = store_at(tmp_path) + ingest(store, signal()) + old = store.read_jsonl("events")[0] + promote = tools.promote_signal_to_event + def colliding(row, config): + return {**promote(row, config), "id": old["id"]} + monkeypatch.setattr(tools, "promote_signal_to_event", colliding) + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, signal("fresh", "r2"))["success"] + assert snapshot(store) == before + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_literal_ids_valid_replay_and_stricter_only_narrowing(tmp_path, monkeypatch, legacy): + store = store_at(tmp_path) + row = signal(" literal wrapper ", " r1 ", " item ") + if legacy: + row.pop("artifact_meta") + row.pop("source_revision") + row.update(sensitivity="public_safe", allowed_surfaces=["local", "telegram"]) + created = ingest(store, row) + assert created["signal_id"] == " literal wrapper " + before = snapshot(store) + def forbidden(*args, **kwargs): + raise AssertionError("Replay must not observe or decay") + monkeypatch.setattr(tools, "observe_after_success", forbidden) + monkeypatch.setattr(tools, "_apply_candidate_decay", forbidden) + assert call(store, row)["data"]["duplicate"] + assert snapshot(store) == before + narrower = {**row, "sensitivity": "local_only"} + assert call(store, narrower)["data"]["duplicate"] + after = snapshot(store) + assert after != before + assert call(store, row)["data"]["duplicate"] + assert snapshot(store) == after + event = dict(store.read_jsonl("events")[0]) + assert call(store, event, event=True)["data"]["duplicate"] + assert snapshot(store) == after + + +def test_fresh_revision_coalesces_but_telemetry_does_not(tmp_path): + store = store_at(tmp_path) + one = ingest(store, signal()) + two = ingest(store, signal("fresh", "r2")) + assert not two["duplicate"] and two["coalesced"] + assert two["candidate_id"] == one["candidate_id"] + telemetry = signal("t1") + telemetry.pop("artifact_meta") + telemetry.update(sensor="body.synthetic", source="telemetry", kind="body_pressure", + source_revision="1", metric_family="fiction") + first = ingest(store, telemetry) + second = ingest(store, {**telemetry, "id": "t2", "source_revision": "2"}) + assert not second["duplicate"] and not second["coalesced"] + assert first["candidate_id"] != second["candidate_id"] + + +@pytest.mark.parametrize("stream", ["events", "candidates"]) +def test_fresh_revision_cannot_coalesce_through_duplicate_owner(tmp_path, stream): + store = store_at(tmp_path) + first = ingest(store, signal()) + store.append_jsonl(stream, copy.deepcopy(store.read_jsonl(stream)[0])) + result = ingest(store, signal("fresh", "r2")) + assert not result["coalesced"] and result["candidate_id"] != first["candidate_id"] + + +def test_typed_join_preserves_legacy_and_full_memory_members(tmp_path): + store = store_at(tmp_path) + assert admission.classify_event_claim(store.instance, {}, signals=[]).kind == "legacy" + row = signal() + claim = admission.classify_signal_claim(store.instance, row) + event = promote_signal_to_event(row) + assert admission.classify_event_claim(store.instance, event, signals=[row]) == claim + assert admission.classify_event_claim(store.instance, event, signals=[row, row]).kind == "malformed" + + +@pytest.mark.parametrize("corruption", ["malformed", "legacy_changed", "injected_cache"]) +def test_retained_wrapper_and_raw_projection_cannot_forge_replay(tmp_path, monkeypatch, corruption): + store = store_at(tmp_path) + incoming = signal() + if corruption == "legacy_changed": + incoming.pop("artifact_meta") + incoming.pop("source_revision") + ingest(store, incoming) + rows = store.read_jsonl("signals") + if corruption == "malformed": + rows[0]["source_revision"] = None + elif corruption == "legacy_changed": + incoming["summary"] = "Changed fictional legacy material" + else: + rows[0]["artifact_meta"]["item_id"] = "different" + rows[0]["_admission_claim"] = admission._claim_for_signal(store.instance, incoming)[0] + store.rewrite_jsonl("signals", rows) + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, incoming)["success"] + assert snapshot(store) == before + + +@pytest.mark.parametrize("direction", ["source_to_legacy", "legacy_to_source", "duplicate"]) +def test_event_wrapper_class_collision_and_ambiguity(tmp_path, monkeypatch, direction): + store = store_at(tmp_path) + row = signal() + store.append_jsonl("signals", row) + event = promote_signal_to_event(row) + if direction == "legacy_to_source": + event["source_signal_ids"] = [] + assert call(store, event, event=True)["success"] + incoming = dict(event) + if direction == "duplicate": + store.append_jsonl("events", event) + else: + incoming["source_signal_ids"] = [row["id"]] if direction == "legacy_to_source" else [] + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, incoming, event=True)["success"] + with pytest.raises(ValueError): + tools._append_event_and_create_or_update_candidate(store, event=incoming, config={}) + assert snapshot(store) == before + + +def test_standalone_legacy_event_inverse_and_collision(tmp_path, monkeypatch): + store = store_at(tmp_path) + event = {**promote_signal_to_event(signal()), "source_signal_ids": [], "id": " literal event "} + assert call(store, event, event=True)["success"] + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert call(store, event, event=True)["data"]["duplicate"] + assert not call(store, {**event, "summary": "Different legacy material"}, event=True)["success"] + assert snapshot(store) == before + + +def test_memory_members_order_is_equivalent_but_not_new_evidence(tmp_path, monkeypatch): + store = store_at(tmp_path) + row = signal("memory-one", None) + row.pop("artifact_meta") + row.update(sensor="sensorium.memory_reflection", source="memory", + memory_provenance={"provider": "fiction", "bank_id": "bank", "item_ids": ["a", "b"]}) + ingest(store, row) + before = snapshot(store) + replay = {**row, "id": "memory-two", "summary": "Other representation", + "memory_provenance": {"provider": "fiction", "bank_id": "bank", "item_ids": ["b", "a"]}} + forbid_effects(store, monkeypatch) + assert call(store, replay)["data"]["duplicate"] + assert snapshot(store) == before + + +@pytest.mark.parametrize("stream", ["signals", "events"]) +def test_distinct_equivalent_retained_wrappers_do_not_select_first(tmp_path, monkeypatch, stream): + store = store_at(tmp_path) + ingest(store, signal()) + row = dict(store.read_jsonl(stream)[0]) + store.append_jsonl(stream, {**row, "id": "second-wrapper"}) + incoming = {**row, "id": "third-wrapper"} + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, incoming, event=stream == "events")["success"] + assert snapshot(store) == before + + +def test_malformed_fresh_claim_does_not_fall_back_to_legacy(tmp_path, monkeypatch): + store = store_at(tmp_path) + row = signal("fresh") + row["source_revision"] = None + row["_admission_claim"] = admission._claim_for_signal(store.instance, signal())[0] + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, row)["success"] + assert snapshot(store) == before From 6a1ff66743794c997299ea49f2ea0209e590843e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 08:48:46 +0200 Subject: [PATCH 10/16] Revalidate related candidate eligibility after decay --- agent_sensorium/tools.py | 3 ++ tests/test_canonical_ingress_replay.py | 50 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index 4fcfff3..36b6572 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -725,6 +725,9 @@ def _append_event_and_create_or_update_candidate( candidate = event_to_candidate(event, config) if related: related = lookup_record(record_index(candidates), related["id"]).row + # Decay replaces rows and can retire the owner selected by preflight. + if related and (related.get("status", "candidate") != "candidate" or related.get("extinct")): + related = None if related: receipt = _coalesce_candidate_with_event( store, diff --git a/tests/test_canonical_ingress_replay.py b/tests/test_canonical_ingress_replay.py index 8313433..6a4253a 100644 --- a/tests/test_canonical_ingress_replay.py +++ b/tests/test_canonical_ingress_replay.py @@ -310,3 +310,53 @@ def test_malformed_fresh_claim_does_not_fall_back_to_legacy(tmp_path, monkeypatc forbid_effects(store, monkeypatch) assert not call(store, row)["success"] assert snapshot(store) == before + + +@pytest.mark.parametrize("owner", ["signal", "trusted_event", "internal"]) +def test_new_revision_survives_prior_owner_extinction(tmp_path, monkeypatch, owner): + store = store_at(tmp_path) + first = ingest(store, signal()) + rows = store.read_jsonl("candidates") + rows[0].update(created_at="2000-01-01T00:00:00Z", updated_at="2000-01-01T00:00:00Z") + old_event_ids = list(rows[0]["event_ids"]) + store.rewrite_jsonl("candidates", rows) + observers = [] + monkeypatch.setattr(tools, "observe_after_success", + lambda *args, **kwargs: observers.append((args[2], args[3]))) + fresh = signal("new-wrapper", "r2") + if owner == "signal": + response = call(store, fresh) + assert response["success"] + result = response["data"] + else: + store.append_jsonl("signals", fresh) + event = promote_signal_to_event(fresh) + if owner == "trusted_event": + response = call(store, event, event=True) + assert response["success"] + result = response["data"] + else: + result = tools._append_event_and_create_or_update_candidate(store, event=event, config={}) + after = store.read_jsonl("candidates") + actual = dict(owner=owner, result=result, candidates=after, observers=observers) + print("EXTINCTION_OBSERVATION " + json.dumps(actual, sort_keys=True)) + old = next(row for row in after if row["id"] == first["candidate_id"]) + assert old["extinct"] and old["status"] == "suppressed" + assert not result["coalesced"], actual + assert result["candidate_id"] != first["candidate_id"], actual + new = next(row for row in after if row["id"] == result["candidate_id"]) + assert new["status"] == "candidate" and not new.get("extinct") + assert len(after) == 2 + assert result["event_id"] != first["event_id"] + assert old["event_ids"] == old_event_ids + assert new["event_ids"] == [result["event_id"]] + assert not store.read_jsonl("decisions") + expected_stages = {"signal": ["source_observed", "candidate_updated"], + "trusted_event": ["candidate_updated"], "internal": []} + assert [stage for stage, _ in observers] == expected_stages[owner] + if observers: + stage, evidence = observers[-1] + assert stage == "candidate_updated" + assert evidence["change_state"] == "new" + assert evidence["candidate_id"] == new["id"] + assert evidence["event_id"] == result["event_id"] From 17d9723fbb4724c815ae1788938a4c7e12c39131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 11:46:24 +0200 Subject: [PATCH 11/16] Close candidate and thread disclosure on canonical source owners --- agent_sensorium/attention.py | 15 +- agent_sensorium/config.py | 117 +++++- agent_sensorium/conscious_aperture.py | 9 +- agent_sensorium/gate.py | 3 + agent_sensorium/pointers.py | 19 +- agent_sensorium/schemas.py | 41 +- agent_sensorium/tools.py | 178 +++++--- agent_sensorium/volunteer_cards.py | 15 +- docs/attention-and-workroom/architecture.md | 6 + docs/attention-and-workroom/change-scope.md | 2 + docs/attention-and-workroom/migration.md | 6 + tests/test_canonical_ingress_replay.py | 141 +++++++ tests/test_closed_visibility.py | 436 ++++++++++++++++++++ tests/test_status_surface_visibility.py | 13 + tests/test_tools.py | 25 +- 15 files changed, 932 insertions(+), 94 deletions(-) create mode 100644 tests/test_closed_visibility.py diff --git a/agent_sensorium/attention.py b/agent_sensorium/attention.py index 5a00981..1e87ed1 100644 --- a/agent_sensorium/attention.py +++ b/agent_sensorium/attention.py @@ -11,7 +11,7 @@ import re import json -from .config import SENSITIVITY_RANK, load_instance_config, visible_on_surface +from .config import DisclosureVisibility, SENSITIVITY_RANK, load_instance_config, visible_on_surface from .schemas import parse_utc_z_checkpoint, truncate_text, utc_now_iso from .store import SensoriumStore @@ -342,7 +342,7 @@ def _ongoing_thread_item( def _ongoing_thread_group( threads: list[dict], decisions: list[dict], *, surface: str, - config: dict, previous_activation_at: str | None, + config: dict, previous_activation_at: str | None, disclosure: DisclosureVisibility, ) -> dict: grouped: dict[str, list[dict]] = {"held": [], "dormant": []} duplicate_ids: set[str] = set() @@ -374,7 +374,7 @@ def _ongoing_thread_group( thread_id = thread.get("id") if not isinstance(thread_id, str) or not thread_id: continue - if visible_on_surface(thread, surface, config): + if disclosure.subject("thread", thread_id): grouped[thread["status"]].append(thread) for status in grouped: grouped[status] = [row for row in grouped[status] if row.get("id") not in duplicate_ids] @@ -383,7 +383,8 @@ def _ongoing_thread_group( selected = eligible[:MAX_ONGOING_THREADS] omitted_held = max(0, len(grouped["held"]) - sum(row.get("status") == "held" for row in selected)) omitted_dormant = max(0, len(grouped["dormant"]) - sum(row.get("status") == "dormant" for row in selected)) - projected = [_ongoing_thread_item(row, decisions, previous_activation_at) for row in selected] + visible_decisions = [row for row in decisions if disclosure.linked(row)] + projected = [_ongoing_thread_item(row, visible_decisions, previous_activation_at) for row in selected] detail_truncated_count = sum(bool(row["truncated"]) for row in projected) group = { "available": True, @@ -455,6 +456,7 @@ def build_attention_inbox( candidates = store.read_jsonl("candidates") threads = store.read_jsonl("threads") + disclosure = DisclosureVisibility(candidates, threads, surface, instance_config) active_candidates = [c for c in candidates if c.get("status") == "candidate"] visible_threads = [t for t in threads if t.get("status") in ("dormant", "held")] @@ -463,13 +465,13 @@ def build_attention_inbox( filtered_out = 0 for c in active_candidates: - if _closed_visible(c, surface, instance_config): + if disclosure.subject("candidate", c.get("id")): items.append(_candidate_item(c, now)) else: filtered_out += 1 for t in visible_threads: - if _closed_visible(t, surface, instance_config): + if disclosure.subject("thread", t.get("id")): items.append(_thread_item(t, now)) else: filtered_out += 1 @@ -504,5 +506,6 @@ def build_attention_inbox( result["ongoing_threads"] = _ongoing_thread_group( threads, decision_rows, surface=surface, config=instance_config, previous_activation_at=previous_activation_at, + disclosure=disclosure, ) return result diff --git a/agent_sensorium/config.py b/agent_sensorium/config.py index 15ab36d..ebf2dc2 100644 --- a/agent_sensorium/config.py +++ b/agent_sensorium/config.py @@ -14,11 +14,13 @@ import tempfile from pathlib import Path +from .canonical_records import lookup_record, record_index from .gate import DEFAULT_CONFIG as GATE_DEFAULT_CONFIG from .schemas import ( PROFILE_NAME_RE, # noqa: F401 - re-exported for config API compatibility SENSITIVITY_RANK, VALID_SENSITIVITIES, + decode_visibility, sanitize_profile_name, utc_now_iso, ) @@ -478,6 +480,7 @@ def resolve_config_path( def _validate_config(raw: dict) -> dict: + invalid_visibility = not isinstance(raw, dict) if not isinstance(raw, dict): raw = {} @@ -525,14 +528,16 @@ def _validate_config(raw: dict) -> dict: config["policy_card_ref"] = None if "allowed_surfaces" in raw: val = raw["allowed_surfaces"] - if isinstance(val, list) and all(isinstance(s, str) for s in val): - surfaces = sorted({s.strip() for s in val if s.strip()}) - if surfaces: - config["allowed_surfaces"] = surfaces + if decode_visibility({"sensitivity": "private", "allowed_surfaces": val}) is None: + invalid_visibility = True + else: + config["allowed_surfaces"] = list(val) if "max_sensitivity" in raw: val = raw["max_sensitivity"] - if val in VALID_SENSITIVITIES: + if isinstance(val, str) and val in VALID_SENSITIVITIES: config["max_sensitivity"] = val + else: + invalid_visibility = True if "thresholds" in raw: val = raw["thresholds"] if isinstance(val, dict): @@ -609,6 +614,9 @@ def _validate_config(raw: dict) -> dict: config["prospective_evidence_capture"] = sanitize_prospective_evidence_capture( raw.get("prospective_evidence_capture") ) + if invalid_visibility: + # Keep documented config display defaults, never their authority. + config["_visibility_invalid"] = True return config @@ -785,15 +793,100 @@ def visible_on_surface(item: dict, surface: str, config: dict) -> bool: Checks BOTH item allowed_surfaces AND config allowed_surfaces, plus sensitivity. Fails closed: missing surfaces or sensitivity data hides item. """ - if not surface: + if not isinstance(surface, str) or not surface.strip(): + return False + item_visibility = decode_visibility(item) + policy_visibility = decode_visibility(config, sensitivity_key="max_sensitivity") + if item_visibility is None or policy_visibility is None: return False - item_surfaces = set(item.get("allowed_surfaces") or []) - config_surfaces = set(config.get("allowed_surfaces") or []) - if surface not in item_surfaces or surface not in config_surfaces: + sensitivity, surfaces = item_visibility + maximum, policy_surfaces = policy_visibility + return (surface in surfaces and surface in policy_surfaces + and SENSITIVITY_RANK[sensitivity] <= SENSITIVITY_RANK[maximum]) + + +class DisclosureVisibility: + """Read-only candidate/thread authority over one full canonical view. + + Uniqueness precedes lifecycle/ranking. Historical subjects use current + metadata without admission preparation. Cyclic/unresolved subjects deny. + Action/artifact entity uniqueness remains owned elsewhere. + """ + + def __init__(self, candidates: list[dict], threads: list[dict], surface: str, config: dict): + self.indexes = {"candidate": record_index(candidates), "thread": record_index(threads)} + self.surface = surface + self.config = config + + def subject(self, kind: str, identifier: object, trail: frozenset = frozenset()) -> bool: + found = lookup_record(self.indexes[kind], identifier) + if found.state != "unique": + return False + key = (kind, identifier) + if key in trail: + return False + return (visible_on_surface(found.row, self.surface, self.config) + and self.linked(found.row, trail | {key}, owner=key)) + + def linked(self, record: object, trail: frozenset = frozenset(), *, owner=None) -> bool: + if not isinstance(record, dict): + return False + linked = False + for field, kind in (("candidate_id", "candidate"), ("origin_candidate_id", "candidate"), + ("thread_id", "thread")): + identifier = record.get(field) + if identifier is None: + continue + linked = True + # Own entity declarations in embedded capsules are not dependency + # cycles; source bindings and origin links remain dependencies. + if field in {"candidate_id", "thread_id"} and (kind, identifier) == owner: + continue + if not self.subject(kind, identifier, trail): + return False + if "source_candidate_ids" in record: + identifiers = record["source_candidate_ids"] + if not isinstance(identifiers, list): + return False + for identifier in identifiers: + linked = True + if not self.subject("candidate", identifier, trail): + return False + binding = record.get("admission_binding") + if binding is not None: + linked = True + if not isinstance(binding, dict) or not self.subject("candidate", binding.get("source_candidate_id"), trail): + return False + for field, value in record.items(): + if field in {"admission_binding", "source_candidate_ids"}: + continue + children = [value] if isinstance(value, dict) else value if isinstance(value, list) else [] + for child in children: + if isinstance(child, dict): + if not self.linked(child, trail, owner=owner): + return False + linked = linked or self._declares_subject(child) + if "source_refs" in record and not isinstance(record["source_refs"], (dict, list)): + return False + if not linked and record.get("type") == "subconscious.advisory": + if (record.get("output_action") in ("DROP", "SAVE", "CREATE_CONSCIOUS_TASK") + or record.get("action") in ("drop", "save", "would_create_conscious_task", + "created_conscious_task_candidate", "updated_conscious_task_candidate")): + return False + return True + + @staticmethod + def _declares_subject(record: dict) -> bool: + """Find canonical linkage, not opaque provenance strings.""" + for key, value in record.items(): + if key in {"candidate_id", "origin_candidate_id", "thread_id", + "admission_binding", "source_candidate_ids"} and value is not None: + return True + children = [value] if isinstance(value, dict) else value if isinstance(value, list) else [] + if any(isinstance(child, dict) and DisclosureVisibility._declares_subject(child) + for child in children): + return True return False - item_rank = SENSITIVITY_RANK.get(item.get("sensitivity", "private"), 1) - max_rank = SENSITIVITY_RANK.get(config.get("max_sensitivity", "private"), 1) - return item_rank <= max_rank def apply_surface_policy( diff --git a/agent_sensorium/conscious_aperture.py b/agent_sensorium/conscious_aperture.py index 227ecb6..e4dfb97 100644 --- a/agent_sensorium/conscious_aperture.py +++ b/agent_sensorium/conscious_aperture.py @@ -14,7 +14,7 @@ from datetime import UTC, datetime, timedelta from typing import Callable -from .config import load_instance_config, visible_on_surface +from .config import DisclosureVisibility, load_instance_config, visible_on_surface from .prospective_evidence import attention_snapshot_evidence, observe_after_success from .schemas import new_id, parse_utc_z_checkpoint, truncate_text, utc_now_iso from .store import ( @@ -473,6 +473,10 @@ def open_conscious_aperture( candidates, now=now_dt, stale_after_minutes=stale_after_minutes ), ) + # None retains the private lease-selection API, not disclosure authority. + disclosure = (DisclosureVisibility(candidates, store.read_jsonl("threads"), surface, + instance_config if isinstance(instance_config, dict) else {}) + if surface is not None else None) canonical_ids = _canonical_source_ids(candidates) active = [ candidate @@ -523,6 +527,7 @@ def open_conscious_aperture( and _visible( candidate, surface=surface, instance_config=instance_config ) + and (disclosure is None or disclosure.subject("candidate", candidate.get("id"))) ] if explicit_consumer else [] @@ -545,6 +550,8 @@ def open_conscious_aperture( continue if not _visible(candidate, surface=surface, instance_config=instance_config): continue + if disclosure is not None and not disclosure.subject("candidate", candidate.get("id")): + continue if recovery_candidate: recovery_eligible.append(candidate) elif fresh_candidate: diff --git a/agent_sensorium/gate.py b/agent_sensorium/gate.py index 47019ec..6ef1ef8 100644 --- a/agent_sensorium/gate.py +++ b/agent_sensorium/gate.py @@ -8,6 +8,7 @@ from .schemas import ( new_id, utc_now_iso, + validate_ingress_visibility, ) DEFAULT_CONFIG: dict = { @@ -340,6 +341,7 @@ def should_promote_supported_signal( def promote_signal_to_event(signal: dict, config: dict | None = None) -> dict: + validate_ingress_visibility(signal) now = utc_now_iso() event = { "id": new_id("evt"), @@ -366,6 +368,7 @@ def promote_signal_to_event(signal: dict, config: dict | None = None) -> dict: def event_to_candidate(event: dict, config: dict | None = None) -> dict: + validate_ingress_visibility(event) now = utc_now_iso() strength = event.get("strength", 0.5) pressure = round(strength * 0.6 + 0.2 * 0.5 + 0.2 * 0.5, 3) diff --git a/agent_sensorium/pointers.py b/agent_sensorium/pointers.py index c617261..e42bb85 100644 --- a/agent_sensorium/pointers.py +++ b/agent_sensorium/pointers.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone from .actions import count_active_actions_for_thread -from .config import load_instance_config, resolve_hermes_surface, visible_on_surface +from .config import DisclosureVisibility, load_instance_config, resolve_hermes_surface from .schemas import truncate_text, utc_now_iso from .store import SensoriumStore from .prospective_evidence import observe_after_success @@ -420,8 +420,11 @@ def select_attention_pointer( if inst_cfg is None: inst_cfg, _ = load_instance_config(state_dir=str(store.root)) + candidate_rows = store.read_jsonl("candidates") + thread_rows = store.read_jsonl("threads") + disclosure = DisclosureVisibility(candidate_rows, thread_rows, surface, inst_cfg) threads = [ - t for t in store.read_jsonl("threads") + t for t in thread_rows if t.get("status") in ("dormant", "held") and not t.get("summary_dirty") ] threads.sort(key=lambda t: t.get("created_at", ""), reverse=True) @@ -540,7 +543,7 @@ def _build_saved_residue_pointer(candidate: dict, reason: str) -> dict: cooldown_blocked: list[tuple[dict, str]] = [] for thread in threads: - if not visible_on_surface(thread, surface, inst_cfg): + if not disclosure.subject("thread", thread.get("id")): continue open_, reason = _cooldown_open(store, thread.get("id", ""), int(cfg.get("cooldown_minutes", 120))) if not open_: @@ -567,8 +570,8 @@ def _build_saved_residue_pointer(candidate: dict, reason: str) -> dict: } if cfg.get("candidate_fallback_enabled", True): candidates = [ - c for c in store.read_jsonl("candidates") - if c.get("status") == "candidate" and visible_on_surface(c, surface, inst_cfg) + c for c in candidate_rows + if c.get("status") == "candidate" and disclosure.subject("candidate", c.get("id")) ] for candidate in sorted(candidates, key=_candidate_sort_key): open_, reason = _cooldown_open( @@ -632,8 +635,10 @@ def _saved_residue_candidates( max_age_days = merged_cfg.get("saved_residue_max_age_days") max_items = merged_cfg.get("saved_residue_max_items") saved = [] - for candidate in store.read_jsonl("candidates"): - if not visible_on_surface(candidate, surface, cfg_for_visibility): + candidates = store.read_jsonl("candidates") + disclosure = DisclosureVisibility(candidates, store.read_jsonl("threads"), surface, cfg_for_visibility) + for candidate in candidates: + if not disclosure.subject("candidate", candidate.get("id")): continue settlement = candidate.get("kanban_settlement") or {} decision = str(settlement.get("decision") or "") diff --git a/agent_sensorium/schemas.py b/agent_sensorium/schemas.py index d661bea..72a59ae 100644 --- a/agent_sensorium/schemas.py +++ b/agent_sensorium/schemas.py @@ -94,7 +94,36 @@ def truncate_text(text: str, max_chars: int = 120) -> str: return cut + "…" +def decode_visibility(metadata: object, *, sensitivity_key: str = "sensitivity") -> tuple[str, list[str]] | None: + """Decode explicit retained authority without coercion or fallback.""" + if not isinstance(metadata, dict) or metadata.get("_visibility_invalid") is True: + return None + sensitivity = metadata.get(sensitivity_key) + surfaces = metadata.get("allowed_surfaces") + if (not isinstance(sensitivity, str) or sensitivity not in SENSITIVITY_RANK + or not isinstance(surfaces, list) + or any(not isinstance(value, str) or not value.strip() for value in surfaces)): + return None + return sensitivity, surfaces + + +def validate_ingress_visibility(metadata: object) -> None: + """Missing ingress fields have safe defaults; malformed PRESENT fields deny.""" + if not isinstance(metadata, dict): + raise ValueError("invalid_visibility_metadata") + sensitivity = metadata.get("sensitivity", "private") + if not isinstance(sensitivity, str) or sensitivity not in SENSITIVITY_RANK: + raise ValueError("Invalid sensitivity") + effective = { + "sensitivity": sensitivity, + "allowed_surfaces": metadata.get("allowed_surfaces", ["local"]), + } + if decode_visibility(effective) is None: + raise ValueError("invalid_visibility_metadata") + + def normalize_signal(raw: dict) -> dict: + validate_ingress_visibility(raw) sig = dict(raw) if "id" not in sig: sig["id"] = new_id("sig") @@ -121,13 +150,11 @@ def classify_feedback_outcome(outcome: str) -> str: def validate_signal(signal: dict) -> None: + validate_ingress_visibility(signal) missing = SIGNAL_REQUIRED_FIELDS - signal.keys() if missing: raise ValueError(f"Signal missing required fields: {missing}") - if signal.get("sensitivity") and signal["sensitivity"] not in VALID_SENSITIVITIES: - raise ValueError( - f"Invalid sensitivity: {signal['sensitivity']}. Must be one of {VALID_SENSITIVITIES}" - ) + if signal.get("source") == "feedback": fb_missing = FEEDBACK_REQUIRED_FIELDS - signal.keys() if fb_missing: @@ -158,14 +185,12 @@ def validate_signal(signal: dict) -> None: def validate_event(event: dict) -> None: + validate_ingress_visibility(event) required = {"id", "ts", "type", "kind", "summary"} missing = required - event.keys() if missing: raise ValueError(f"Event missing required fields: {missing}") - if event.get("sensitivity") and event["sensitivity"] not in VALID_SENSITIVITIES: - raise ValueError( - f"Invalid sensitivity: {event['sensitivity']}. Must be one of {VALID_SENSITIVITIES}" - ) + def merge_sensitivity(values: list[str]) -> str: diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index 36b6572..47ee84a 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -156,39 +156,9 @@ def _status_record_visible( or index readiness. Unresolved advisory choices carry source prose, not global diagnostics; genuinely unlinked operational diagnostics remain global. """ - from .config import visible_on_surface - - subjects = [(record.get(key), rows) for key, rows in ( - ("candidate_id", candidates), ("origin_candidate_id", candidates), - ("thread_id", threads), - )] - binding = record.get("admission_binding") - if binding is not None: - if not isinstance(binding, dict): - return False - source_id = binding.get("source_candidate_id") - if not isinstance(source_id, str) or not source_id.strip(): - return False - subjects.append((source_id, candidates)) + from .config import DisclosureVisibility - linked = False - for subject_id, rows in subjects: - if subject_id is None: - continue - if not isinstance(subject_id, str) or not subject_id.strip(): - return False - linked = True - subject = next((row for row in rows if row.get("id") == subject_id), None) - if subject is None or not visible_on_surface(subject, surface, instance_config): - return False - if not linked and record.get("type") == "subconscious.advisory": - if (record.get("output_action") in ("DROP", "SAVE", "CREATE_CONSCIOUS_TASK") - or record.get("action") in ( - "drop", "save", "would_create_conscious_task", - "created_conscious_task_candidate", "updated_conscious_task_candidate", - )): - return False - return True + return DisclosureVisibility(candidates, threads, surface, instance_config).linked(record) def handle_sensorium_status( @@ -207,7 +177,7 @@ def handle_sensorium_status( historical prose using current visibility. Filtering precedes top-N caps; list shapes and ranking remain compatible. Coarse counts stay global. """ - from .config import load_instance_config, visible_on_surface + from .config import DisclosureVisibility, load_instance_config store = SensoriumStore(instance=instance, state_dir=state_dir) store.ensure_dirs() @@ -223,14 +193,15 @@ def handle_sensorium_status( artifacts = store.read_jsonl("artifacts") state = store.read_state() - status_surface = surface or "local" + status_surface = "local" if isinstance(surface, str) and surface == "" else surface + disclosure = DisclosureVisibility(candidates, threads, status_surface, instance_config) active_candidates = [c for c in candidates if c.get("status") == "candidate"] visible_candidates = [c for c in active_candidates - if visible_on_surface(c, status_surface, instance_config)] + if disclosure.subject("candidate", c.get("id"))] visible_candidates.sort(key=lambda c: c.get("pressure", 0), reverse=True) visible_threads = [t for t in threads if t.get("status") in ("dormant", "held") - and visible_on_surface(t, status_surface, instance_config)] + and disclosure.subject("thread", t.get("id"))] visible_threads.sort(key=lambda t: t.get("created_at", ""), reverse=True) active_threads = [t for t in threads if t.get("status") in ("dormant", "held")] @@ -376,8 +347,90 @@ def _stored_candidate_fingerprint(candidate: dict) -> str: def _candidate_for_event_id(candidates: list[dict], event_id: str) -> dict | None: - return _unique_match(candidates, [row for row in candidates - if event_id in (row.get("event_ids") or [])]) + """Resolve source ownership, not advisory evidence membership. + + Existing source edges prove derivation only after the entire connected + lineage resolves uniquely and its event membership agrees. Binding prose, + fingerprints, kinds and retained order confer no ownership authority. + """ + index = record_index(candidates) + resolved: dict[str, set[str]] = {} + visiting: set[str] = set() + + def refs(row, key): + values = row.get(key, []) + if (not isinstance(values, list) + or any(not isinstance(value, str) or not value for value in values) + or len(set(values)) != len(values)): + raise ValueError("ambiguous_ingress_lineage") + return values + + def roots(identifier): + found = lookup_record(index, identifier) + if found.state != "unique" or identifier in visiting: + raise ValueError("ambiguous_ingress_lineage") + if identifier in resolved: + return resolved[identifier] + row = found.row + assert row is not None + events = set(refs(row, "event_ids")) + sources = refs(row, "source_candidate_ids") + metadata = row.get("advisory_meta") + bindings = [row[key] for key in ("admission_binding",) if key in row] + if isinstance(metadata, dict) and "admission_binding" in metadata: + bindings.append(metadata["admission_binding"]) + # None is the existing writer's unbound legacy/multi-source value. + # A present malformed binding must not borrow authority from another. + bindings = [binding for binding in bindings if binding is not None] + for binding in bindings: + if (not isinstance(binding, dict) + or not isinstance(binding.get("source_candidate_id"), str) + or not binding["source_candidate_id"] + or binding["source_candidate_id"] not in sources): + raise ValueError("ambiguous_ingress_lineage") + if bindings and any(binding != bindings[0] for binding in bindings[1:]): + raise ValueError("ambiguous_ingress_lineage") + visiting.add(identifier) + owners = set() + evidence = set() + for source_id in sources: + owners.update(roots(source_id)) + source = lookup_record(index, source_id).row + assert source is not None + source_events = set(refs(source, "event_ids")) + if events and not events.intersection(source_events): + raise ValueError("ambiguous_ingress_lineage") + evidence.update(source_events) + if sources and not events.issubset(evidence): + raise ValueError("ambiguous_ingress_lineage") + visiting.remove(identifier) + resolved[identifier] = owners if sources else {identifier} + return resolved[identifier] + + matches = [row for row in candidates if event_id in (row.get("event_ids") or [])] + if not matches: + return None + owners = set() + for row in matches: + owners.update(roots(row.get("id"))) + # Validate descendants even when they retain only source IDs, before the + # existing narrowing owner can propagate any visibility restriction. + while True: + descendants = [row for row in candidates + if any(isinstance(ref, str) and ref in resolved + for ref in (row.get("source_candidate_ids") or []))] + prior = len(resolved) + for row in descendants: + roots(row.get("id")) + if len(resolved) == prior: + break + event_owners = [] + for owner in owners: + row = lookup_record(index, owner).row + assert row is not None + if event_id in refs(row, "event_ids"): + event_owners.append(row) + return _unique_match(candidates, event_owners) def _candidate_for_event_fingerprint(candidates: list[dict], event: dict) -> dict | None: @@ -709,6 +762,9 @@ def _append_event_and_create_or_update_candidate( event: dict, config: dict | None = None, ) -> dict: + from .schemas import validate_ingress_visibility + + validate_ingress_visibility(event) events = store.read_jsonl("events") signals = store.read_jsonl("signals") candidates = store.read_jsonl("candidates") @@ -1425,12 +1481,21 @@ def _resolve_exact_subject( it is no longer reachable" from "I never saw this id". This is the exact-subject counterpart to the rotating top-N status shape. """ - from .config import visible_on_surface + from .canonical_records import lookup_record + from .config import DisclosureVisibility from .pointers import DEFAULT_POINTER_CONFIG cfg = instance_config if isinstance(instance_config, dict) else {} out: dict = {"id": reference_id} + disclosure = DisclosureVisibility(candidates, threads, surface, cfg) + matches = [lookup_record(disclosure.indexes[kind], reference_id) + for kind in ("candidate", "thread")] + if sum(match.state != "missing" for match in matches) != 1 or any( + match.state == "ambiguous" for match in matches + ): + return {"id": reference_id, "kind": "unknown", "not_found": True, + "reason": "no_unique_subject"} for thread in threads: if thread.get("id") != reference_id: @@ -1439,7 +1504,7 @@ def _resolve_exact_subject( if status not in ("dormant", "held"): return {"id": reference_id, "kind": "unknown", "not_found": True, "reason": f"thread_status_{status or 'unset'}"} - if not visible_on_surface(thread, surface, cfg): + if not disclosure.subject("thread", thread.get("id")): return {"id": reference_id, "kind": "thread", "not_found": True, "surface": surface, "reason": "surface_not_allowed"} task = thread.get("conscious_task") or {} @@ -1467,7 +1532,7 @@ def _resolve_exact_subject( and decision in {"SAVE", "PROMOTE_CONSCIOUS"} and settlement.get("intake_task_id") ) - if not visible_on_surface(candidate, surface, cfg): + if not disclosure.subject("candidate", candidate.get("id")): kind_guess = "saved_residue" if is_saved_residue else "candidate" return {"id": reference_id, "kind": kind_guess, "not_found": True, "surface": surface, "reason": "surface_not_allowed"} @@ -1566,7 +1631,8 @@ def handle_sensorium_candidate_open( candidates. The capsule explicitly says it is a candidate, not a thread, so the surface-facing pointer/doorway does not have to invent a thread. """ - from .config import load_instance_config, visible_on_surface + from .canonical_records import lookup_record + from .config import DisclosureVisibility, load_instance_config store = SensoriumStore(instance=instance, state_dir=state_dir) store.ensure_dirs() @@ -1576,13 +1642,11 @@ def handle_sensorium_candidate_open( ) candidates = store.read_jsonl("candidates") + disclosure = DisclosureVisibility(candidates, store.read_jsonl("threads"), surface, instance_config) target = None if candidate_id and candidate_id != "latest": - for c in candidates: - if c.get("id") == candidate_id: - target = c - break - if target is None: + target = lookup_record(disclosure.indexes["candidate"], candidate_id).row + if not candidate_id or candidate_id == "latest": # Honour acceptance (b): when "latest" is requested for a surface, # prefer the highest-pressure active candidate on that surface and # fall back to the highest-pressure saved-residue candidate when no @@ -1590,7 +1654,7 @@ def handle_sensorium_candidate_open( active_visible = [ c for c in candidates if c.get("status") == "candidate" - and visible_on_surface(c, surface, instance_config) + and disclosure.subject("candidate", c.get("id")) ] if active_visible: active_visible.sort(key=lambda c: (c.get("pressure") or 0), reverse=True) @@ -1600,7 +1664,7 @@ def handle_sensorium_candidate_open( c for c in candidates if (c.get("kanban_settlement") or {}).get("decision") in {"SAVE", "PROMOTE_CONSCIOUS"} and (c.get("kanban_settlement") or {}).get("intake_task_id") - and visible_on_surface(c, surface, instance_config) + and disclosure.subject("candidate", c.get("id")) ] if saved_visible: saved_visible.sort(key=lambda c: (c.get("pressure") or 0), reverse=True) @@ -1612,7 +1676,7 @@ def handle_sensorium_candidate_open( f"No viewable candidate for surface '{surface}' (id='{candidate_id or 'latest'}'); " "the agent should call sensorium(action='status', surface=...) instead.", ) - if not visible_on_surface(target, surface, instance_config): + if not disclosure.subject("candidate", target.get("id")): return _err( instance, f"Candidate '{target.get('id')}' is not allowed on surface '{surface}'.", @@ -1660,7 +1724,8 @@ def handle_sensorium_thread_open( state_dir: str | None = None, config_path: str | None = None, ) -> str: - from .config import load_instance_config, visible_on_surface + from .canonical_records import lookup_record + from .config import DisclosureVisibility, load_instance_config store = SensoriumStore(instance=instance, state_dir=state_dir) store.ensure_dirs() @@ -1673,9 +1738,18 @@ def handle_sensorium_thread_open( target = _find_thread(threads, thread_id) if target is None: return _err(instance, f"Thread '{thread_id or 'latest'}' not found.") + disclosure = DisclosureVisibility(store.read_jsonl("candidates"), threads, surface, instance_config) + if not thread_id or thread_id == "latest": + eligible = [t for t in threads if t.get("status") in _VISIBLE_STATUSES + and disclosure.subject("thread", t.get("id"))] + target = _find_thread(eligible, thread_id) + else: + target = lookup_record(disclosure.indexes["thread"], thread_id).row + if target is None: + return _err(instance, "No unique viewable thread.") if target.get("status") not in _VISIBLE_STATUSES: return _err(instance, f"Thread '{target.get('id')}' is {target.get('status')} and cannot be opened.") - if not visible_on_surface(target, surface, instance_config): + if not disclosure.subject("thread", target.get("id")): return _err(instance, f"Thread '{target.get('id')}' is not allowed on surface '{surface}'.") capsule = _compact_thread_capsule(target) diff --git a/agent_sensorium/volunteer_cards.py b/agent_sensorium/volunteer_cards.py index e3ee92b..a2c9b07 100644 --- a/agent_sensorium/volunteer_cards.py +++ b/agent_sensorium/volunteer_cards.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone from .attention import _freshness, _safe_atom, _safe_atom_list, _safe_text -from .config import load_instance_config, visible_on_surface +from .config import DisclosureVisibility, load_instance_config, visible_on_surface from .pointers import DEFAULT_POINTER_CONFIG, _saved_residue_candidates from .schemas import truncate_text from .store import SensoriumStore @@ -267,17 +267,20 @@ def build_volunteer_cards( now = datetime.now(timezone.utc) ranked_cards: list[tuple[tuple, dict]] = [] + candidate_rows = store.read_jsonl("candidates") + thread_rows = store.read_jsonl("threads") + disclosure = DisclosureVisibility(candidate_rows, thread_rows, surface, inst_cfg) threads = [ - t for t in store.read_jsonl("threads") - if t.get("status") in {"dormant", "held"} and visible_on_surface(t, surface, inst_cfg) + t for t in thread_rows + if t.get("status") in {"dormant", "held"} and disclosure.subject("thread", t.get("id")) ] for thread in threads: ranked_cards.append(_thread_card(thread, now)) candidates = [ - c for c in store.read_jsonl("candidates") - if c.get("status") == "candidate" and visible_on_surface(c, surface, inst_cfg) + c for c in candidate_rows + if c.get("status") == "candidate" and disclosure.subject("candidate", c.get("id")) ] for candidate in candidates: ranked_cards.append(_candidate_card(candidate, now)) @@ -289,7 +292,7 @@ def build_volunteer_cards( ranked_cards.append(_saved_residue_card(candidate, now)) for artifact in store.read_jsonl("artifacts"): - if not visible_on_surface(artifact, surface, inst_cfg): + if not visible_on_surface(artifact, surface, inst_cfg) or not disclosure.linked(artifact): continue artifact_card = _artifact_card(artifact, now) if artifact_card is not None: diff --git a/docs/attention-and-workroom/architecture.md b/docs/attention-and-workroom/architecture.md index 299160e..9baff7f 100644 --- a/docs/attention-and-workroom/architecture.md +++ b/docs/attention-and-workroom/architecture.md @@ -67,6 +67,12 @@ The option defaults off and creates no automatic native caller, resume, task or ## Proposed context and finite tools +### Candidate and thread disclosure + +The candidate uses one read-only `DisclosureVisibility` owner backed by the canonical record index. Full-stream uniqueness precedes disclosure, lifecycle filtering and top-N output: byte-identical duplicates and active/terminal pairs remain ambiguous. Status, exact subjects, candidate/saved-residue/thread opens, attention and optional latent metadata, volunteer cards and pointers gate declared current and historical canonical links, including nested admission bindings and source-reference mappings. Missing, malformed, dangling, ambiguous or cyclic subjects withhold source-bearing prose; genuine unlinked operational diagnostics and coarse counts remain global. + +Retained visibility requires a closed sensitivity rank and literal nonempty string surface entries in valid item and operator lists. An empty list is valid deny-all. Configured custom surfaces use literal membership, never an unknown-to-local fallback. Status retains its empty-string local default; type-invalid falsy requests cannot inherit that default. Missing ingress visibility retains its normalization defaults, while malformed-present metadata is rejected before effects. Disclosure does not prepare admission, decay candidates or write observers. Explicit-surface aperture selection uses the same owner; the existing internal `surface=None` lease-selection API is not a disclosure request. Action/artifact entity uniqueness and runtime integration remain separately owned. This implementation requires independent review and whole-source verification before publication or adoption. + Canonical entity IDs are literal and must be unique, including byte-identical duplicates. Ambiguous candidates have no admission slot; ambiguous advisories supply no settlement. A unique candidate with missing, malformed or ambiguous joins may retain an uncertain candidate-scoped legacy consideration, never producer identity. Projections preserve invalid reference members rather than filtering them into stronger authority. Snapshot-local uniqueness indexes are constructed once per immutable view; the existing prior scan remains quadratic, and this is not a hard VM-step ceiling. Producer-reserved projection fields remain untrusted outside structurally owned internal snapshots. Tools ingress (B) and lineage visibility (C) remain future-gated; this foundation does not implement those owners. A trusted external approver supplies immutable context, including nested values, binding runtime/package-manager selection, interpreter/ABI/import origins, selected native route/configuration, canonical SessionDB/profile/tier/episode owner, artifact owner, memory read owner and source authorization. A self-digest is consistency, not approval. Observed drift causes a typed **HOLD**, never silent rebinding, updated hashes, copied bindings, alternate interpreter, route or history fallback. diff --git a/docs/attention-and-workroom/change-scope.md b/docs/attention-and-workroom/change-scope.md index 85fb9f2..4db0e10 100644 --- a/docs/attention-and-workroom/change-scope.md +++ b/docs/attention-and-workroom/change-scope.md @@ -27,6 +27,8 @@ This candidate implements three attention changes and documents a native/workroo ## Retained protections +Candidate/thread visibility closure is implemented in this source candidate, pending independent review. One canonical full-stream disclosure owner gates ordinary status, exact subjects, active/saved opens, attention/latent metadata, volunteer cards, pointers and explicit-surface aperture selection. Linked historical prose uses current subject visibility; nested canonical source links cannot bypass it. The shared pure gate rejects malformed shapes and honors valid empty deny-all and literal custom surfaces. Coarse counts and unlinked operational diagnostics remain global. This adds no identity registry, admission settlement, live effects or action/artifact entity uniqueness guarantee; complete-suite, contribution privacy and publication gates remain separate. + The canonical admission foundation rejects ambiguous candidate/advisory authority, preserves malformed joins without projection filtering and constructs uniqueness indexes once per immutable snapshot. Unique uncertain candidates may retain legacy first consideration, never a fresh producer revision. Existing quadratic prior scans and measured SQL work are not a new hard execution budget. Tools ingress (B) now classifies canonical claims and complete typed event joins before replay or effects, rejecting wrapper collisions and ambiguous replay descendants. Its local implementation requires independent acceptance; lineage visibility policy (C) remains held and unchanged. No cache/schema, fingerprint protocol or observer contract is changed by B. - **Instance/config/store/live/admin:** canonical instance validation before effects, no live arbitrary state-root injection, small ordinary tool surface, explicit admin and generic configurable profiles. diff --git a/docs/attention-and-workroom/migration.md b/docs/attention-and-workroom/migration.md index c12c32a..7b79138 100644 --- a/docs/attention-and-workroom/migration.md +++ b/docs/attention-and-workroom/migration.md @@ -12,6 +12,12 @@ Exact consumer/aperture generation/lease/presentation ownership remains required ## Native adoption is proposed, never implicit +The candidate/thread read boundary now withholds ambiguous full-stream identities and unresolved canonical linked prose without repairing or rewriting retained ledgers. Missing or malformed retained visibility is not ingress-default authority. Valid empty surface lists remain literal deny-all; custom surfaces require literal item and operator membership. This disclosure implementation is separately review-gated and does not authorize runtime migration, install or adoption. Existing source/replay migration rules below remain unchanged. + +Populated trusted-event `source_signal_ids` denote locally retained, uniquely resolved membership, not external provenance. Standalone trusted legacy events may use `source_signal_ids=[]`; `signal_count` remains historical metadata and creates no local signals. Older imports with populated external IDs but no retained signal rows are deliberately unsupported and rejected without effects. Narrowing a fixture to empty references verifies standalone legacy import, not preservation of that older external-reference input. + +A derived advisory may retain its source event IDs as evidence without becoming another replay owner. Existing source-candidate links and admission bindings must resolve consistently to unique retained rows with matching event membership; absent legacy bindings do not excuse malformed present bindings, dangling links, cycles or independent competing roots. Replay returns the original source candidate, and visibility restrictions still propagate through derived evidence and threads. + Duplicate entity IDs remain ambiguous until canonical rewrite restores uniqueness; append replacement IDs are not a supported revision contract. Rebuild and dependency refresh withdraw ambiguous advisory settlement without rewriting canonical history. Missing or malformed joins of a unique candidate remain uncertain legacy where policy permits, not source equality. Fixture completion of a missing distinct event is legacy-to-source; a valid source-to-source transition instead rewrites the unique canonical event or updates candidate references to a fresh distinct event. Ingress rejects malformed claims, incomplete trusted-event joins and non-equivalent wrapper reuse without domain or observer effects. Producers must supply a fresh wrapper for changed material; synthetic fixtures must do the same rather than weakening collision assertions. Valid equal legacy replay and canonical producer replay remain supported. B requires independent acceptance; visibility policy (C) remains separately held. A future generic extension must import cold without private files/dependencies, path discovery, registry mutation, provider initialization or inference. No embedded deployment root, endpoint, memory target, grant or ledger path. Immutable externally approved context pins runtime/package-manager generation/interpreter/ABI/import origins, selected route/model/configuration, canonical DB/profile/tier/root, artifact owner, memory read owner and source authorization. Current observations must match; drift HOLDs. Code upgrades or relocation require fresh approval, never copied bindings or automatic digest refresh. Accounting authority persists through route/runtime changes. diff --git a/tests/test_canonical_ingress_replay.py b/tests/test_canonical_ingress_replay.py index 6a4253a..d40321f 100644 --- a/tests/test_canonical_ingress_replay.py +++ b/tests/test_canonical_ingress_replay.py @@ -360,3 +360,144 @@ def test_new_revision_survives_prior_owner_extinction(tmp_path, monkeypatch, own assert evidence["change_state"] == "new" assert evidence["candidate_id"] == new["id"] assert evidence["event_id"] == result["event_id"] + + +def create_role_advisory(store, candidate): + response = json.loads(tools.handle_sensorium_subconscious_advisory( + instance=store.instance, state_dir=str(store.root), enabled=True, dry_run=False, + advisory_output={"action": "CREATE_CONSCIOUS_TASK", "rationale": "Synthetic role evidence", + "candidate_ids": [candidate["id"]], "event_ids": candidate["event_ids"], + "conscious_task": {"request_type": "THINK", "title": "Synthetic task", + "why": "Evaluate", "expected_decision": "Hold or engage"}}, + config={"model_enabled": False}, record_receipt=True)) + assert response["success"], response + return store.read_jsonl("candidates")[-1] + + +@pytest.mark.parametrize("event", [False, True]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_independent_replay_root_rejected(tmp_path, monkeypatch, event, reverse): + store = store_at(tmp_path) + ingest(store, signal()) + root = store.read_jsonl("candidates")[0] + competing = {**root, "id": "independent-root"} + rows = [root, competing] + store.rewrite_jsonl("candidates", rows[::-1] if reverse else rows) + incoming = dict(store.read_jsonl("events")[0]) if event else signal() + incoming.update(sensitivity="local_only", allowed_surfaces=[]) + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, incoming, event=event)["success"] + assert snapshot(store) == before + + +@pytest.mark.parametrize("event", [False, True]) +@pytest.mark.parametrize("restriction", ["surface", "sensitivity"]) +def test_real_advisory_replay_returns_source_and_only_narrows(tmp_path, monkeypatch, event, restriction): + from agent_sensorium.dispatcher import candidate_to_thread + + store = store_at(tmp_path) + first = signal() + first.update(sensitivity="public_safe", allowed_surfaces=["local", "telegram"]) + original = ingest(store, first) + root = store.read_jsonl("candidates")[0] + derived = create_role_advisory(store, root) + assert derived["event_ids"] == root["event_ids"] + assert derived["source_candidate_ids"] == [root["id"]] + for row in [root, derived]: + store.append_jsonl("threads", candidate_to_thread(row)) + # Reverse retention order: role is not append-position authority. + store.rewrite_jsonl("candidates", [derived, root]) + incoming = dict(store.read_jsonl("events")[0]) if event else dict(first) + before = snapshot(store) + with monkeypatch.context() as effects: + forbid_effects(store, effects) + replay = call(store, incoming, event=event) + assert replay["success"] and replay["data"]["duplicate"] + assert replay["data"]["candidate_id"] == original["candidate_id"] + assert replay["data"]["event_id"] == original["event_id"] + if not event: + assert replay["data"]["signal_id"] == original["signal_id"] + assert snapshot(store) == before + def forbidden(*args, **kwargs): + raise AssertionError("Narrowing must not append, observe, decay or prune") + for name in ["observe_after_success", "_apply_candidate_decay", "_read_pruned_sensor_policy"]: + monkeypatch.setattr(tools, name, forbidden) + monkeypatch.setattr(type(store), "append_jsonl", forbidden) + narrower = {**incoming, "allowed_surfaces": ["local"]} if restriction == "surface" else { + **incoming, "sensitivity": "local_only"} + result = call(store, narrower, event=event) + assert result["success"] and result["data"]["candidate_id"] == root["id"] + after = snapshot(store) + assert after != before + for stream in ["events", "candidates", "threads"]: + for row in store.read_jsonl(stream): + assert row["allowed_surfaces"] == ["local"] + if restriction == "sensitivity": + assert row["sensitivity"] == "local_only" + assert {row["id"] for row in store.read_jsonl("candidates")} == {root["id"], derived["id"]} + assert len(store.read_jsonl("events")) == len(store.read_jsonl("signals")) == 1 + with monkeypatch.context() as effects: + forbid_effects(store, effects) + wider = call(store, incoming, event=event) + assert wider["success"] and wider["data"]["candidate_id"] == root["id"] + assert snapshot(store) == after + + +@pytest.mark.parametrize("event", [False, True]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("shape", ["duplicate", "dangling", "conflicting", "cycle", "membership", + "malformed_binding", "malformed_sources", "nested_conflict", + "pseudo_derived", "descendant_binding"]) +def test_structural_replay_lineage_rejected_without_effects(tmp_path, monkeypatch, event, reverse, shape): + store = store_at(tmp_path) + ingest(store, signal()) + root = store.read_jsonl("candidates")[0] + derived = create_role_advisory(store, root) + rows = [root, derived] + if shape == "duplicate": + rows.append(copy.deepcopy(derived)) + elif shape == "dangling": + derived["source_candidate_ids"] = ["absent"] + elif shape == "conflicting": + derived["admission_binding"]["source_candidate_id"] = "absent" + elif shape == "cycle": + root["source_candidate_ids"] = [derived["id"]] + elif shape == "membership": + root["event_ids"] = [] + elif shape == "malformed_binding": + derived["admission_binding"] = [] + elif shape == "malformed_sources": + derived["source_candidate_ids"] = root["id"] + elif shape == "nested_conflict": + derived["advisory_meta"]["admission_binding"]["item_key"] = "contradictory" + elif shape == "pseudo_derived": + derived.pop("source_candidate_ids") + else: + derived["event_ids"] = [] + derived["admission_binding"] = {} + store.rewrite_jsonl("candidates", rows[::-1] if reverse else rows) + incoming = dict(store.read_jsonl("events")[0]) if event else signal() + incoming.update(sensitivity="local_only", allowed_surfaces=[]) + before = snapshot(store) + forbid_effects(store, monkeypatch) + assert not call(store, incoming, event=event)["success"] + with pytest.raises(ValueError, match="ambiguous_ingress_lineage"): + tools._preflight_event(store, store.read_jsonl("events")[0], + events=store.read_jsonl("events"), + signals=store.read_jsonl("signals"), + candidates=store.read_jsonl("candidates")) + assert snapshot(store) == before + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_multi_source_legacy_evidence_has_one_root_per_event(reverse): + one = {"id": "one", "event_ids": ["event-one"]} + two = {"id": "two", "event_ids": ["event-two"]} + derived = {"id": "derived", "event_ids": ["event-one", "event-two"], + "source_candidate_ids": ["one", "two"]} + rows = [one, two, derived] + if reverse: + rows.reverse() + assert tools._candidate_for_event_id(rows, "event-one") is one + assert tools._candidate_for_event_id(rows, "event-two") is two diff --git a/tests/test_closed_visibility.py b/tests/test_closed_visibility.py new file mode 100644 index 0000000..4420493 --- /dev/null +++ b/tests/test_closed_visibility.py @@ -0,0 +1,436 @@ +"""Focused compatibility controls for closed visibility metadata.""" +import pytest + +from agent_sensorium import tools +from agent_sensorium.config import _validate_config, visible_on_surface +from agent_sensorium.gate import promote_signal_to_event +from agent_sensorium.schemas import decode_visibility, normalize_signal, validate_ingress_visibility +from tests.test_canonical_ingress_replay import call, forbid_effects +from tests.test_core_material_replay import ingest, signal, snapshot, store_at + + +@pytest.mark.parametrize("bad", ["secret-invalid-value", None, {}, []]) +def test_invalid_sensitivity_category_without_echo(tmp_path, monkeypatch, bad): + store = store_at(tmp_path) + row = {**signal(), "sensitivity": bad} + before = snapshot(store) + forbid_effects(store, monkeypatch) + with pytest.raises(ValueError, match="^Invalid sensitivity$"): + validate_ingress_visibility(row) + for event in (False, True): + incoming = promote_signal_to_event(signal()) if event else signal() + incoming["sensitivity"] = bad + result = call(store, incoming, event=event) + assert not result["success"] and result["error"] == "Invalid sensitivity" + assert snapshot(store) == before + + +@pytest.mark.parametrize("bad", ["local", {}, None, ["local", 7], [["local"]], [""]]) +def test_malformed_surfaces_reject_before_effects(tmp_path, monkeypatch, bad): + store = store_at(tmp_path) + before = snapshot(store) + forbid_effects(store, monkeypatch) + row = {**signal(), "allowed_surfaces": bad} + event = {**promote_signal_to_event(signal()), "allowed_surfaces": bad} + assert not call(store, row)["success"] + assert not call(store, event, event=True)["success"] + with pytest.raises(ValueError, match="invalid_visibility_metadata"): + tools._append_event_and_create_or_update_candidate(store, event=event, config={}) + assert snapshot(store) == before + + +def test_empty_surfaces_ingress_and_read_inverse(tmp_path): + store = store_at(tmp_path) + ingest(store, {**signal(), "allowed_surfaces": []}) + for stream in ("signals", "events", "candidates"): + row = store.read_jsonl(stream)[0] + assert row["allowed_surfaces"] == [] + assert not visible_on_surface(row, "local", _validate_config({})) + event = {**promote_signal_to_event(signal()), "id": "empty-event", + "source_signal_ids": [], "allowed_surfaces": []} + assert call(store, event, event=True)["success"] + internal = {**event, "id": "empty-internal", "summary": "Internal fictional event"} + tools._append_event_and_create_or_update_candidate(store, event=internal, config={}) + assert all(row["allowed_surfaces"] == [] for row in store.read_jsonl("events")) + assert all(row["allowed_surfaces"] == [] for row in store.read_jsonl("candidates")) + + +def test_empty_replay_narrows_without_observation(tmp_path, monkeypatch): + store = store_at(tmp_path) + row = signal() + ingest(store, row) + def forbidden(*args, **kwargs): + raise AssertionError("Replay must not observe or decay") + monkeypatch.setattr(tools, "observe_after_success", forbidden) + monkeypatch.setattr(tools, "_apply_candidate_decay", forbidden) + assert call(store, {**row, "allowed_surfaces": []})["data"]["duplicate"] + for stream in ("signals", "events", "candidates"): + assert store.read_jsonl(stream)[0]["allowed_surfaces"] == [] + frozen = snapshot(store) + assert call(store, row)["data"]["duplicate"] + assert snapshot(store) == frozen + + +def test_missing_ingress_defaults_are_not_retained_authority(): + row = {key: value for key, value in signal().items() + if key not in {"sensitivity", "allowed_surfaces"}} + normalized = normalize_signal(row) + assert normalized["sensitivity"] == "private" + assert normalized["allowed_surfaces"] == ["local"] + assert decode_visibility(row) is None + assert not visible_on_surface(row, "local", _validate_config({})) + assert not visible_on_surface(normalized, "local", {}) + + +def test_literal_custom_policy_and_empty_policy(): + row = {**signal(), "allowed_surfaces": [" custom "]} + policy = _validate_config({"allowed_surfaces": [" custom "]}) + assert policy["allowed_surfaces"] == [" custom "] + assert visible_on_surface(row, " custom ", policy) + assert not visible_on_surface(row, "custom", policy) + empty = _validate_config({"allowed_surfaces": []}) + assert empty["allowed_surfaces"] == [] + assert not visible_on_surface(signal(), "local", empty) + malformed = _validate_config({"allowed_surfaces": "local"}) + assert malformed["_visibility_invalid"] is True + assert not visible_on_surface(signal(), "local", malformed) + + +@pytest.mark.parametrize("consumer", ["status", "candidate_open", "thread_open"]) +def test_duplicate_subject_withholds_full_serialized_prose(tmp_path, monkeypatch, consumer): + import json + from agent_sensorium import config + from agent_sensorium.dispatcher import candidate_to_thread + store = store_at(tmp_path) + marker = "Fictional ambiguous visibility marker" + candidate = {"id": "candidate-duplicate", "status": "candidate", "summary": marker, + "pressure": 0.9, "sensitivity": "private", "allowed_surfaces": ["local"]} + thread = candidate_to_thread(candidate) + stream, row = ("threads", thread) if consumer == "thread_open" else ("candidates", candidate) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + store.append_jsonl(stream, dict(row)) + monkeypatch.setattr(config, "load_instance_config", lambda **kw: (_validate_config({}), {})) + before = {str(p.relative_to(store.root)): p.read_bytes() + for p in store.root.rglob("*") if p.is_file()} + kwargs = {"instance": store.instance, "state_dir": str(store.root), "surface": "local"} + if consumer == "status": + result = tools.handle_sensorium_status(reference_id=row["id"], **kwargs) + elif consumer == "candidate_open": + result = tools.handle_sensorium_candidate_open(candidate_id=row["id"], **kwargs) + else: + result = tools.handle_sensorium_thread_open(thread_id=row["id"], **kwargs) + assert marker not in json.dumps(json.loads(result)) + assert {str(p.relative_to(store.root)): p.read_bytes() + for p in store.root.rglob("*") if p.is_file()} == before + + +def _consumer_rows(marker): + from tests.test_conscious_aperture import _candidate + from agent_sensorium.dispatcher import candidate_to_thread + candidate = _candidate("candidate-visible") + candidate.update(summary=marker) + candidate["conscious_task"].update(title=marker, why=marker) + thread = candidate_to_thread(candidate) + thread["id"] = "thread-visible" + return candidate, thread + + +def _consumer_snapshot(store): + return {str(p.relative_to(store.root)): p.read_bytes() + for p in store.root.rglob("*") if p.is_file()} + + +def _consumer_outputs(store, surface="local"): + import json + from agent_sensorium.attention import build_attention_inbox + from agent_sensorium.conscious_aperture import open_conscious_aperture + from agent_sensorium.pointers import select_attention_pointer, _saved_residue_candidates + from agent_sensorium.volunteer_cards import build_volunteer_cards + from agent_sensorium.config import load_instance_config + policy, _ = load_instance_config(state_dir=str(store.root)) + kw = {"instance": store.instance, "state_dir": str(store.root), "surface": surface} + outputs = { + "status": json.loads(tools.handle_sensorium_status(**kw)), + "exact_candidate": json.loads(tools.handle_sensorium_status(reference_id="candidate-visible", **kw)), + "exact_thread": json.loads(tools.handle_sensorium_status(reference_id="thread-visible", **kw)), + "candidate_open": json.loads(tools.handle_sensorium_candidate_open(candidate_id="candidate-visible", **kw)), + "candidate_latest": json.loads(tools.handle_sensorium_candidate_open(candidate_id="latest", **kw)), + "thread_open": json.loads(tools.handle_sensorium_thread_open(thread_id="thread-visible", **kw)), + "thread_latest": json.loads(tools.handle_sensorium_thread_open(**kw)), + "attention": build_attention_inbox(store, surface=surface, include_ongoing_threads=True), + "cards": build_volunteer_cards(store, surface=surface, instance_config=policy), + "pointer": select_attention_pointer(store, surface=surface, instance_config=policy), + "saved": _saved_residue_candidates(store, surface, policy), + } + # None is the private internal lease-selection default, not a disclosure + # request. Explicit surface tests exercise the read-only preview seam. + if surface is not None: + outputs["aperture"] = open_conscious_aperture( + store, surface=surface, instance_config=policy, dry_run=True, + now="2026-06-07T12:00:00Z") + return outputs + + +def _prepare_consumers(store, monkeypatch, policy): + from agent_sensorium import config, admission, pointers, conscious_aperture + monkeypatch.setattr(config, "load_instance_config", lambda **kw: (policy, {})) + # Modules with direct imports need the same synthetic operator policy. + for module in (pointers, conscious_aperture): + monkeypatch.setattr(module, "load_instance_config", lambda **kw: (policy, {})) + import agent_sensorium.attention as attention + import agent_sensorium.volunteer_cards as cards + for module in (attention, cards): + monkeypatch.setattr(module, "load_instance_config", lambda **kw: (policy, {})) + with store.candidate_transaction(): + pass + def forbidden(*args, **kwargs): + raise AssertionError("Disclosure must not prepare admission, decay, write or observe") + for name in ("ensure_admission_index_ready",): + if hasattr(admission, name): + monkeypatch.setattr(admission, name, forbidden) + forbid_effects(store, monkeypatch) + monkeypatch.setattr(pointers, "observe_after_success", forbidden) + monkeypatch.setattr(conscious_aperture, "observe_after_success", forbidden) + monkeypatch.setattr(type(store), "prepare_admission_index", forbidden) + + +_RETAINED_NEGATIVES = [ + ("sensitivity", None), ("sensitivity", {}), ("sensitivity", []), + ("sensitivity", "unknown"), ("sensitivity", False), ("sensitivity", "missing"), + ("allowed_surfaces", None), ("allowed_surfaces", {}), ("allowed_surfaces", "local"), + ("allowed_surfaces", ["local", 1]), ("allowed_surfaces", [["local"]]), + ("allowed_surfaces", [""]), ("allowed_surfaces", []), ("allowed_surfaces", "missing"), + ("id", None), ("id", {}), ("id", []), ("id", ""), ("id", "missing"), +] + + +@pytest.mark.parametrize("stream", ["candidates", "threads"]) +@pytest.mark.parametrize("field,value", _RETAINED_NEGATIVES) +def test_all_read_consumers_retained_shape_denial(tmp_path, monkeypatch, stream, field, value): + import json + store = store_at(tmp_path) + marker = "Fictional retained boundary marker" + candidate, thread = _consumer_rows(marker if stream == "candidates" else "Visible parent") + if stream == "threads": + thread["conscious_task"].update(title=marker, why=marker) + thread["continuity_summary"] = [marker] + target = candidate if stream == "candidates" else thread + if value == "missing": + target.pop(field) + else: + target[field] = value + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + _prepare_consumers(store, monkeypatch, _validate_config({})) + before = _consumer_snapshot(store) + for name, output in _consumer_outputs(store).items(): + assert marker not in json.dumps(output), name + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("stream", ["candidates", "threads"]) +@pytest.mark.parametrize("terminal", [False, True]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("saved", [False, True]) +def test_full_stream_duplicate_permutations_all_consumers(tmp_path, monkeypatch, stream, terminal, reverse, saved): + import copy + import json + store = store_at(tmp_path) + marker = "Fictional full stream ambiguity marker" + candidate, thread = _consumer_rows(marker if stream == "candidates" else "Visible parent") + if stream == "threads": + thread["conscious_task"].update(title=marker, why=marker) + thread["continuity_summary"] = [marker] + if saved: + candidate.update(status="archived", kanban_settlement={"decision": "SAVE", "intake_task_id": "intake-fiction"}) + target = candidate if stream == "candidates" else thread + duplicate = copy.deepcopy(target) + if terminal: + duplicate["status"] = "archived" + rows = [target, duplicate] + if reverse: + rows.reverse() + for name, row in [("candidates", candidate), ("threads", thread)]: + for retained in rows if name == stream else [row]: + store.append_jsonl(name, retained) + store.append_jsonl("decisions", {"type": "thread.updated", "thread_id": thread["id"], "reason": marker}) + store.write_state({"last_dispatch_result": {"candidate_id": candidate["id"] if stream == "candidates" else None, + "thread_id": thread["id"], "reason": marker}}) + _prepare_consumers(store, monkeypatch, _validate_config({})) + before = _consumer_snapshot(store) + for name, output in _consumer_outputs(store).items(): + assert marker not in json.dumps(output), name + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("field,value", [ + ("max_sensitivity", None), ("max_sensitivity", {}), ("max_sensitivity", []), + ("max_sensitivity", "unknown"), ("max_sensitivity", "missing"), + ("allowed_surfaces", None), ("allowed_surfaces", {}), ("allowed_surfaces", "local"), + ("allowed_surfaces", ["local", False]), ("allowed_surfaces", []), ("allowed_surfaces", "missing"), +]) +def test_policy_shape_denies_all_consumers(tmp_path, monkeypatch, field, value): + import json + store = store_at(tmp_path) + marker = "Fictional malformed policy marker" + candidate, thread = _consumer_rows(marker) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + policy = _validate_config({}) + if value == "missing": + policy.pop(field) + else: + policy[field] = value + _prepare_consumers(store, monkeypatch, policy) + before = _consumer_snapshot(store) + for name, output in _consumer_outputs(store).items(): + assert marker not in json.dumps(output), name + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("surface", [None, False, 0, [], {}, ["local"]]) +def test_invalid_falsy_request_never_becomes_local(tmp_path, monkeypatch, surface): + import json + store = store_at(tmp_path) + marker = "Fictional invalid request marker" + candidate, thread = _consumer_rows(marker) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + _prepare_consumers(store, monkeypatch, _validate_config({})) + before = _consumer_snapshot(store) + for name, output in _consumer_outputs(store, surface).items(): + assert marker not in json.dumps(output), name + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("link", ["candidate_id", "origin_candidate_id", "thread_id", "source_candidate_ids", + "admission_binding", "nested_binding", "source_refs", "metadata"]) +@pytest.mark.parametrize("state", ["hidden", "missing", "ambiguous", "historical"]) +def test_nested_current_and_historical_links_all_consumers(tmp_path, monkeypatch, link, state): + import json + store = store_at(tmp_path) + marker = "Fictional canonical linked marker" + candidate, thread = _consumer_rows(marker) + source = {"id": "source-subject", "status": "archived", "summary": "Source metadata", + "sensitivity": "private", "allowed_surfaces": [] if state == "hidden" else ["local"]} + if link == "thread_id": + source.update(conscious_task={"title": "Source metadata"}) + stream = "threads" if link == "thread_id" else "candidates" + if state != "missing": + store.append_jsonl(stream, source) + if state == "ambiguous": + store.append_jsonl(stream, dict(source)) + identifier = source["id"] + if link == "source_candidate_ids": + declared = {link: [identifier]} + elif link in {"admission_binding", "nested_binding"}: + declared = {"admission_binding": {"source_candidate_id": identifier}} + if link == "nested_binding": + declared = {"advisory_meta": declared} + elif link in {"source_refs", "metadata"}: + declared = {link: {"candidate_id": identifier}} + else: + declared = {link: identifier} + candidate.update(declared) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + receipt = {"type": "subconscious.advisory", "output_action": "SAVE", "reason": marker, **declared} + store.append_jsonl("decisions", receipt) + store.write_state({"last_dispatch_result": receipt}) + _prepare_consumers(store, monkeypatch, _validate_config({})) + before = _consumer_snapshot(store) + outputs = _consumer_outputs(store) + for name, output in outputs.items(): + if state == "historical" and name not in {"saved"}: + assert marker in json.dumps(output), name + else: + assert marker not in json.dumps(output), name + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("sensitivity", ["local_only", "private", "public_safe"]) +@pytest.mark.parametrize("maximum", ["local_only", "private", "public_safe"]) +@pytest.mark.parametrize("surface,configured", [("local", True), ("custom-room", True), ("custom-room", False)]) +def test_closed_rank_and_custom_positive_inverses(tmp_path, monkeypatch, sensitivity, maximum, surface, configured): + import json + from agent_sensorium.schemas import SENSITIVITY_RANK + store = store_at(tmp_path) + marker = "Fictional closed rank marker" + candidate, thread = _consumer_rows(marker) + for row in (candidate, thread): + row.update(sensitivity=sensitivity, allowed_surfaces=[surface]) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + policy = {"allowed_surfaces": [surface] if configured else ["local"], "max_sensitivity": maximum} + _prepare_consumers(store, monkeypatch, policy) + before = _consumer_snapshot(store) + visible = configured and SENSITIVITY_RANK[sensitivity] <= SENSITIVITY_RANK[maximum] + for name, output in _consumer_outputs(store, surface).items(): + assert (marker in json.dumps(output)) == (visible and name != "saved"), name + assert _consumer_snapshot(store) == before + + +def test_real_default_and_custom_ingress_with_read_inverse(tmp_path, monkeypatch): + from agent_sensorium import config + import json + store = store_at(tmp_path) + default = signal() + default.pop("sensitivity") + default.pop("allowed_surfaces") + assert call(store, default)["success"] + custom = {**signal("custom-signal", item="custom"), "allowed_surfaces": ["custom-room"]} + result = call(store, custom) + assert result["success"] + monkeypatch.setattr(config, "load_instance_config", lambda **kw: (_validate_config({"allowed_surfaces": ["custom-room"]}), {})) + opened = json.loads(tools.handle_sensorium_candidate_open(candidate_id=result["data"]["candidate_id"], + surface="custom-room", instance=store.instance, state_dir=str(store.root))) + assert opened["success"] + + +def test_cycles_own_identity_and_exact_missing_do_not_fallback(tmp_path, monkeypatch): + import json + from agent_sensorium.config import DisclosureVisibility + store = store_at(tmp_path) + candidate, thread = _consumer_rows("Fictional own identity marker") + candidate["candidate_id"] = candidate["id"] + policy = _validate_config({}) + assert DisclosureVisibility([candidate], [thread], "local", policy).subject("candidate", candidate["id"]) + cyclic = {**candidate, "source_candidate_ids": [candidate["id"]]} + assert not DisclosureVisibility([cyclic], [thread], "local", policy).subject("candidate", candidate["id"]) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + _prepare_consumers(store, monkeypatch, policy) + before = _consumer_snapshot(store) + result = json.loads(tools.handle_sensorium_candidate_open(candidate_id="missing", instance=store.instance, + state_dir=str(store.root))) + assert not result["success"] and result["data"] is None + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("state", ["valid_saved", "self_cycle", "pair_cycle", "bad_nested_binding"]) +def test_saved_positive_and_actual_cycle_consumers(tmp_path, monkeypatch, state): + import json + store = store_at(tmp_path) + marker = "Fictional saved cycle boundary marker" + candidate, thread = _consumer_rows(marker) + candidate.update(status="archived", kanban_settlement={"decision": "SAVE", "intake_task_id": "intake-fiction"}) + if state == "self_cycle": + candidate["source_candidate_ids"] = [candidate["id"]] + elif state == "pair_cycle": + candidate["source_candidate_ids"] = ["cycle-peer"] + peer = {**candidate, "id": "cycle-peer", "source_candidate_ids": [candidate["id"]]} + store.append_jsonl("candidates", peer) + elif state == "bad_nested_binding": + candidate["advisory_meta"] = {"admission_binding": {"source_candidate_id": []}} + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + _prepare_consumers(store, monkeypatch, _validate_config({})) + before = _consumer_snapshot(store) + outputs = _consumer_outputs(store) + for name, output in outputs.items(): + if state == "valid_saved" and name not in {"aperture"}: + assert marker in json.dumps(output), name + else: + assert marker not in json.dumps(output), name + assert _consumer_snapshot(store) == before diff --git a/tests/test_status_surface_visibility.py b/tests/test_status_surface_visibility.py index 37f7671..f5499e2 100644 --- a/tests/test_status_surface_visibility.py +++ b/tests/test_status_surface_visibility.py @@ -212,6 +212,15 @@ def test_bound_advisory_reason_obeys_current_source(tmp_path, monkeypatch, actio assert receipt['type'] == 'subconscious.advisory' assert receipt['admission_binding']['source_candidate_id'] == candidate['id'] assert (receipt['candidate_id'] is None) == (dry_run or action != 'CREATE_CONSCIOUS_TASK') + from tests.test_canonical_ingress_replay import forbid_effects + before_replay = _snapshot(store) + with monkeypatch.context() as replay_patch: + forbid_effects(store, replay_patch) + replay = ingest(store, first) + assert replay['candidate_id'] == candidate['id'] + assert replay['event_id'] == candidate['event_ids'][0] + assert replay['signal_id'] == first['id'] + assert _snapshot(store) == before_replay kw = {'instance': store.instance, 'state_dir': str(store.root), 'surface': 'telegram'} assert marker in json.dumps(json.loads(tools.handle_sensorium_status(**kw))) incoming = dict(first) @@ -220,6 +229,10 @@ def test_bound_advisory_reason_obeys_current_source(tmp_path, monkeypatch, actio else: incoming['sensitivity'] = 'local_only' assert ingest(store, incoming)['duplicate'] + narrowed_owner = ingest(store, incoming) + assert narrowed_owner['candidate_id'] == candidate['id'] + assert narrowed_owner['event_id'] == candidate['event_ids'][0] + assert narrowed_owner['signal_id'] == first['id'] before = _snapshot(store) forbidden = json.loads(tools.handle_sensorium_status(reference_id=candidate['id'], **kw)) assert forbidden['data']['exact_subject']['not_found'] diff --git a/tests/test_tools.py b/tests/test_tools.py index 279611a..6db3e17 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -174,6 +174,27 @@ def test_duplicate_weak_signal_remains_noise_without_extra_records(self, state_d class TestSensoriumIngestEvent: + def test_external_reference_import_rejected_without_effects(self, state_dir, monkeypatch): + from agent_sensorium import tools + from tests.test_canonical_ingress_replay import forbid_effects + + store = SensoriumStore(instance="test", state_dir=state_dir) + store.ensure_dirs() + event = { + "id": "evt_trusted", "ts": "2026-05-25T00:00:00Z", + "type": "sensor.event.promoted", "kind": "task_result", + "summary": "Trusted sensor already promoted this event", + "source_signal_ids": ["sig_external"], "signal_count": 2, + "strength": 0.88, "correlation_keys": ["trusted-import"], + "sensitivity": "private", "allowed_surfaces": ["local", "discord"], + } + before = {str(p): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} + forbid_effects(store, monkeypatch) + result = json.loads(tools.handle_sensorium_ingest_event( + event=event, instance="test", state_dir=state_dir)) + assert not result["success"] and result["error"] == "invalid_ingress_claim" + assert {str(p): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} == before + def test_valid_event_ingested_and_candidate_created(self, state_dir): event = { "id": "evt_trusted", @@ -181,7 +202,7 @@ def test_valid_event_ingested_and_candidate_created(self, state_dir): "type": "sensor.event.promoted", "kind": "task_result", "summary": "Trusted sensor already promoted this event", - "source_signal_ids": ["sig_external"], + "source_signal_ids": [], "signal_count": 2, "strength": 0.88, "correlation_keys": ["trusted-import"], @@ -230,7 +251,7 @@ def test_duplicate_event_import_is_idempotent(self, state_dir): "type": "sensor.event.promoted", "kind": "task_result", "summary": "Trusted duplicate event", - "source_signal_ids": ["sig_external"], + "source_signal_ids": [], "strength": 0.9, "correlation_keys": ["trusted-dedupe"], "sensitivity": "private", From 137147d234d30dfee2e0673b52eb5ebd1c25b3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 15:12:32 +0200 Subject: [PATCH 12/16] Close canonical disclosure and source compatibility contracts --- agent_sensorium/config.py | 77 ++++--- agent_sensorium/dispatcher.py | 25 +- tests/test_actions.py | 7 + tests/test_artifacts.py | 2 + tests/test_attention.py | 10 + ..._attention_lifecycle_review_regressions.py | 2 +- tests/test_config.py | 11 +- tests/test_conscious_owner_regressions.py | 10 +- tests/test_core_latent_awareness.py | 4 +- tests/test_kanban_native_invariants.py | 15 +- tests/test_p2_admission.py | 17 +- tests/test_p2_review_regressions.py | 8 +- tests/test_p3_composed_attention.py | 3 + tests/test_p4_memory_identity_policy.py | 3 +- tests/test_pointers.py | 9 + tests/test_source_closure_contract.py | 214 ++++++++++++++++++ tests/test_thread_lifecycle.py | 5 + tests/test_thread_service.py | 4 + tests/test_volunteer_cards.py | 1 + 19 files changed, 378 insertions(+), 49 deletions(-) create mode 100644 tests/test_source_closure_contract.py diff --git a/agent_sensorium/config.py b/agent_sensorium/config.py index ebf2dc2..262db3c 100644 --- a/agent_sensorium/config.py +++ b/agent_sensorium/config.py @@ -1,9 +1,9 @@ """Instance config loading, validation, and policy application. Config is loaded from (in order): explicit config_path argument, then -{state_dir}/instance.config.json, then safe defaults. Missing or corrupt -config falls back to safe defaults — local-only surfaces, private sensitivity, -minimal budgets. +{state_dir}/instance.config.json, then safe defaults. Absent config uses +local-only defaults; unreadable present config retains diagnostic defaults +without disclosure authority. Policy functions only narrow scope; they never broaden an item's sensitivity or allowed_surfaces beyond what the item already has. @@ -470,11 +470,23 @@ def resolve_config_path( ) -> Path | None: if config_path: p = Path(config_path) - if p.is_file(): + try: + p.lstat() + except FileNotFoundError: + pass + except OSError: + return p + else: return p if state_dir: p = Path(state_dir) / "instance.config.json" - if p.is_file(): + try: + p.lstat() + except FileNotFoundError: + pass + except OSError: + return p + else: return p return None @@ -642,8 +654,9 @@ def load_instance_config( try: raw = json.loads(path.read_text()) - except (json.JSONDecodeError, OSError): + except (json.JSONDecodeError, OSError, UnicodeError): config = _validate_config({}) + config["_visibility_invalid"] = True diag = _config_diagnostics(config, source="defaults", path=str(path)) diag["error"] = "config_unreadable" return config, diag @@ -829,13 +842,26 @@ def subject(self, kind: str, identifier: object, trail: frozenset = frozenset()) and self.linked(found.row, trail | {key}, owner=key)) def linked(self, record: object, trail: frozenset = frozenset(), *, owner=None) -> bool: + return self._linked(record, trail, owner=owner)[0] + + def _linked(self, record: object, trail: frozenset = frozenset(), *, owner=None, + source_refs=False) -> tuple[bool, bool]: + """Return validity and resolved linkage from the same traversal.""" if not isinstance(record, dict): - return False + return False, False linked = False for field, kind in (("candidate_id", "candidate"), ("origin_candidate_id", "candidate"), ("thread_id", "thread")): identifier = record.get(field) - if identifier is None: + # Legacy operational receipts use null for absent direct subjects. + # Inside canonical source_refs only the documented string placeholder + # can be absent; null or other falsy members remain malformed. + if field not in record or (identifier is None and not source_refs): + continue + # The canonical artifact writer retains this absence beside a + # resolved candidate parent, not in arbitrary subject declarations. + if (source_refs and field == "thread_id" and identifier == "" and linked + and isinstance(record.get("candidate_id"), str) and record["candidate_id"]): continue linked = True # Own entity declarations in embedded capsules are not dependency @@ -843,50 +869,39 @@ def linked(self, record: object, trail: frozenset = frozenset(), *, owner=None) if field in {"candidate_id", "thread_id"} and (kind, identifier) == owner: continue if not self.subject(kind, identifier, trail): - return False + return False, False if "source_candidate_ids" in record: identifiers = record["source_candidate_ids"] if not isinstance(identifiers, list): - return False + return False, False for identifier in identifiers: linked = True if not self.subject("candidate", identifier, trail): - return False + return False, False binding = record.get("admission_binding") if binding is not None: linked = True if not isinstance(binding, dict) or not self.subject("candidate", binding.get("source_candidate_id"), trail): - return False + return False, False for field, value in record.items(): if field in {"admission_binding", "source_candidate_ids"}: continue children = [value] if isinstance(value, dict) else value if isinstance(value, list) else [] for child in children: if isinstance(child, dict): - if not self.linked(child, trail, owner=owner): - return False - linked = linked or self._declares_subject(child) + valid, resolved = self._linked(child, trail, owner=owner, + source_refs=field == "source_refs" and isinstance(value, dict)) + if not valid: + return False, False + linked = linked or resolved if "source_refs" in record and not isinstance(record["source_refs"], (dict, list)): - return False + return False, False if not linked and record.get("type") == "subconscious.advisory": if (record.get("output_action") in ("DROP", "SAVE", "CREATE_CONSCIOUS_TASK") or record.get("action") in ("drop", "save", "would_create_conscious_task", "created_conscious_task_candidate", "updated_conscious_task_candidate")): - return False - return True - - @staticmethod - def _declares_subject(record: dict) -> bool: - """Find canonical linkage, not opaque provenance strings.""" - for key, value in record.items(): - if key in {"candidate_id", "origin_candidate_id", "thread_id", - "admission_binding", "source_candidate_ids"} and value is not None: - return True - children = [value] if isinstance(value, dict) else value if isinstance(value, list) else [] - if any(isinstance(child, dict) and DisclosureVisibility._declares_subject(child) - for child in children): - return True - return False + return False, False + return True, linked def apply_surface_policy( diff --git a/agent_sensorium/dispatcher.py b/agent_sensorium/dispatcher.py index 715d31e..c3c9230 100644 --- a/agent_sensorium/dispatcher.py +++ b/agent_sensorium/dispatcher.py @@ -17,6 +17,7 @@ from pathlib import Path from .gate import is_feedback_self_loop +from .config import DisclosureVisibility, load_instance_config from .actuator_contracts import mediated_artifact_review_contract from .schemas import new_id, truncate_text, utc_now_iso from .store import SensoriumStore @@ -398,12 +399,29 @@ def _dispatch_core( budgets: dict, ) -> dict: candidates = store.read_jsonl("candidates") - selected = select_candidate(candidates, cfg) + existing_threads = store.read_jsonl("threads") + eligible = candidates + if not dry_run and cfg.get("legacy_thread_dispatch_enabled"): + # Keep the full canonical view for uniqueness, including terminal rows. + surfaces = cfg.get("allowed_surfaces") + surfaces = surfaces if isinstance(surfaces, list) else [] + disclosures = [DisclosureVisibility(candidates, existing_threads, surface, cfg) + for surface in surfaces] + eligible = [c for c in candidates if any( + disclosure.subject("candidate", c.get("id")) for disclosure in disclosures + )] + usable = [] + for candidate in eligible: + preview = candidate_to_thread(candidate, cfg) + if any(DisclosureVisibility(candidates, [*existing_threads, preview], surface, cfg) + .subject("thread", preview["id"]) for surface in surfaces): + usable.append(candidate) + eligible = usable + selected = select_candidate(eligible, cfg) if selected is None: return {"action": "no_candidate", "reason": "No eligible candidate above threshold."} - existing_threads = store.read_jsonl("threads") for t in existing_threads: if t.get("origin_candidate_id") == selected["id"]: return { @@ -478,7 +496,8 @@ def dispatch_once( dry_run: bool = True, config: dict | None = None, ) -> dict: - cfg = _merged_config(config) + instance_config, _ = load_instance_config(state_dir=str(store.root)) + cfg = _merged_config({**instance_config, **(config or {})}) store.ensure_dirs() budgets = _budget_state(store, cfg) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1dbb69f..0414377 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -12,6 +12,7 @@ ) from agent_sensorium.schemas import validate_signal from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_thread_parents from agent_sensorium.tools import ( handle_sensorium_action_attach, handle_sensorium_action_prepare, @@ -708,6 +709,7 @@ def test_thread_open_includes_actions_when_visible(self, state_dir): store.ensure_dirs() _write_config(state_dir, surfaces=["local"]) store.append_jsonl("threads", _make_thread(allowed_surfaces=["local"])) + retain_thread_parents(store) prepare_action( store, @@ -731,6 +733,7 @@ def test_thread_open_omits_terminal_actions(self, state_dir): store.ensure_dirs() _write_config(state_dir, surfaces=["local"]) store.append_jsonl("threads", _make_thread(allowed_surfaces=["local"])) + retain_thread_parents(store) prep = prepare_action( store, @@ -766,6 +769,7 @@ def test_thread_open_omits_actions_blocked_by_surface(self, state_dir): )) # Action inherits thread surfaces ["local", "discord"] + retain_thread_parents(store) prepare_action( store, thread_id="sth_test1", @@ -789,6 +793,7 @@ def test_thread_open_no_actions_key_when_empty(self, state_dir): store.ensure_dirs() _write_config(state_dir, surfaces=["local"]) store.append_jsonl("threads", _make_thread(allowed_surfaces=["local"])) + retain_thread_parents(store) raw = handle_sensorium_thread_open( instance="test", state_dir=state_dir, @@ -810,6 +815,7 @@ def test_pointer_includes_action_count(self, state_dir): store.ensure_dirs() _write_config(state_dir, surfaces=["local"]) store.append_jsonl("threads", _make_thread(allowed_surfaces=["local"])) + retain_thread_parents(store) prepare_action( store, @@ -835,6 +841,7 @@ def test_pointer_no_action_count_when_no_actions(self, state_dir): store.ensure_dirs() _write_config(state_dir, surfaces=["local"]) store.append_jsonl("threads", _make_thread(allowed_surfaces=["local"])) + retain_thread_parents(store) from agent_sensorium.config import load_instance_config inst_cfg, _ = load_instance_config(state_dir=state_dir) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 70bf463..6298649 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -11,6 +11,7 @@ ) from agent_sensorium.actions import prepare_action from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_thread_parents from agent_sensorium.tools import ( handle_sensorium_artifact_store, handle_sensorium_artifact_status, @@ -240,6 +241,7 @@ def test_thread_open_includes_only_compact_artifact_refs(self, state_dir): store.ensure_dirs() _write_config(state_dir, surfaces=["local", "discord"], max_sensitivity="private") store.append_jsonl("threads", _make_thread(allowed_surfaces=["local", "discord"])) + retain_thread_parents(store) store_artifact( store, kind="video", diff --git a/tests/test_attention.py b/tests/test_attention.py index 196c7af..27f5ef4 100644 --- a/tests/test_attention.py +++ b/tests/test_attention.py @@ -14,6 +14,7 @@ ) from agent_sensorium.config import visible_on_surface from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_thread_parents from agent_sensorium.tools import handle_sensorium_attention_inbox @@ -166,6 +167,7 @@ def test_excludes_non_active_candidates(self, store): def test_includes_dormant_and_held_threads(self, store): store.append_jsonl("threads", _make_thread("sth_dormant", status="dormant")) store.append_jsonl("threads", _make_thread("sth_held", status="held", hold_reason="waiting")) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") assert inbox["counts"]["threads"] == 2 ids = [i["id"] for i in inbox["items"]] @@ -176,6 +178,7 @@ def test_excludes_closed_and_archived_threads(self, store): store.append_jsonl("threads", _make_thread("sth_dormant")) store.append_jsonl("threads", _make_thread("sth_closed", status="closed")) store.append_jsonl("threads", _make_thread("sth_archived", status="archived")) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") assert inbox["counts"]["threads"] == 1 assert inbox["items"][0]["id"] == "sth_dormant" @@ -185,6 +188,7 @@ def test_surface_filtering_hides_items(self, store): store.append_jsonl("candidates", _make_candidate("cand_dash", surfaces=["dashboard"])) store.append_jsonl("threads", _make_thread("sth_local", surfaces=["local"])) store.append_jsonl("threads", _make_thread("sth_dash", surfaces=["dashboard"])) + retain_thread_parents(store) inbox = build_attention_inbox( store, surface="local", config={"allowed_surfaces": ["local", "dashboard"]}, @@ -233,6 +237,7 @@ def test_candidate_compact_fields(self, store): def test_thread_compact_fields(self, store): store.append_jsonl("threads", _make_thread("sth_1")) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") item = inbox["items"][0] assert item["type"] == "thread" @@ -248,6 +253,7 @@ def test_held_thread_shows_hold_reason(self, store): store.append_jsonl("threads", _make_thread( "sth_held", status="held", hold_reason="waiting for evidence", resume_trigger="new_data", )) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") item = inbox["items"][0] assert item["hold_reason"] == "waiting for evidence" @@ -261,6 +267,7 @@ def test_candidate_allowed_decisions(self, store): def test_dormant_thread_allowed_decisions(self, store): store.append_jsonl("threads", _make_thread("sth_1", status="dormant")) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") item = inbox["items"][0] assert item["allowed_decisions"] == THREAD_DECISIONS["dormant"] @@ -269,6 +276,7 @@ def test_dormant_thread_allowed_decisions(self, store): def test_held_thread_allowed_decisions(self, store): store.append_jsonl("threads", _make_thread("sth_1", status="held")) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") item = inbox["items"][0] assert item["allowed_decisions"] == THREAD_DECISIONS["held"] @@ -278,6 +286,7 @@ def test_held_thread_allowed_decisions(self, store): def test_threads_sorted_before_candidates(self, store): store.append_jsonl("candidates", _make_candidate("cand_1", pressure=0.9)) store.append_jsonl("threads", _make_thread("sth_1")) + retain_thread_parents(store) inbox = build_attention_inbox(store, surface="local") assert inbox["items"][0]["type"] == "thread" assert inbox["items"][1]["type"] == "candidate" @@ -368,6 +377,7 @@ def test_handler_with_data(self, state_dir): store.ensure_dirs() store.append_jsonl("candidates", _make_candidate("cand_1")) store.append_jsonl("threads", _make_thread("sth_1")) + retain_thread_parents(store) result = json.loads(handle_sensorium_attention_inbox( instance="test", state_dir=state_dir, surface="local", )) diff --git a/tests/test_attention_lifecycle_review_regressions.py b/tests/test_attention_lifecycle_review_regressions.py index 7a4d8ba..8e9ef68 100644 --- a/tests/test_attention_lifecycle_review_regressions.py +++ b/tests/test_attention_lifecycle_review_regressions.py @@ -428,7 +428,7 @@ def test_same_owner_yield_dry_run_ineligible_and_no_contender_are_nonmutating(tm before = {name: store.read_jsonl(name) for name in ("candidates", "decisions")} preview = open_conscious_aperture( store, aperture_size=1, max_active_items=1, consumer_id="same-owner", - surface="local", instance_config={"allowed_surfaces": ["local", "discord"]}, + surface="local", instance_config={"allowed_surfaces": ["local", "discord"], "max_sensitivity": "private"}, dry_run=True, now="2026-06-07T12:02:00Z", ) assert preview["action"] == "resumed_aperture" diff --git a/tests/test_config.py b/tests/test_config.py index a235d92..dc2e47e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ load_instance_config, manage_attention_policy_config, resolve_config_path, + visible_on_surface, ) from agent_sensorium.gate import DEFAULT_CONFIG as GATE_DEFAULT_CONFIG from agent_sensorium.store import SensoriumStore @@ -129,7 +130,10 @@ def test_blank_surface_values_are_ignored(self, tmp_path): cfg = tmp_path / "instance.config.json" cfg.write_text(json.dumps({"allowed_surfaces": ["discord", "", " ", "local"]})) config, _ = load_instance_config(config_path=str(cfg)) - assert config["allowed_surfaces"] == ["discord", "local"] + assert config["allowed_surfaces"] == ["local"] + assert config["_visibility_invalid"] is True + for surface in ("local", "discord"): + assert not visible_on_surface({"sensitivity": "private", "allowed_surfaces": [surface]}, surface, config) def test_blank_only_surface_values_fall_back_to_safe_default(self, tmp_path): cfg = tmp_path / "instance.config.json" @@ -168,7 +172,10 @@ def test_empty_surfaces_list_ignored(self, tmp_path): cfg = tmp_path / "instance.config.json" cfg.write_text(json.dumps({"allowed_surfaces": []})) config, _ = load_instance_config(config_path=str(cfg)) - assert config["allowed_surfaces"] == ["local"] + assert config["allowed_surfaces"] == [] + assert not config.get("_visibility_invalid", False) + for surface in ("local", "discord"): + assert not visible_on_surface({"sensitivity": "private", "allowed_surfaces": [surface]}, surface, config) def test_state_dir_config_loaded(self, tmp_path): sd = tmp_path / "state" diff --git a/tests/test_conscious_owner_regressions.py b/tests/test_conscious_owner_regressions.py index 9ba7afb..67b88f2 100644 --- a/tests/test_conscious_owner_regressions.py +++ b/tests/test_conscious_owner_regressions.py @@ -22,6 +22,7 @@ from agent_sensorium.conscious_doorway import handle_conscious_doorway_pre_llm from agent_sensorium.outbox import _indexed_outbox_request, prepare_local_outbox_request from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_candidate_parent ROOT = Path(__file__).parents[1] START = "2026-09-15T11:00:00Z" @@ -221,6 +222,7 @@ def test_real_doorway_refuses_full_presentation_index_before_resume_or_new_claim (root / "instance.config.json").write_text(json.dumps({"conscious_doorway": {"enabled": True}})) store = _store(root) store.append_jsonl("candidates", _advisory("active")) + retain_candidate_parent(store, "source_1") first = handle_conscious_doorway_pre_llm( instance="test", platform="local", session_id="same-session", turn_id="turn-0", state_dir=str(root) ) @@ -240,6 +242,7 @@ def test_real_doorway_refuses_full_presentation_index_before_resume_or_new_claim ) assert result["success"] is True store.append_jsonl("candidates", _advisory("fresh", "revision-2")) + retain_candidate_parent(store, "source_1") before_candidates = store.paths["candidates"].read_bytes() before_decisions = store.paths["decisions"].read_bytes() prior_expiry = store.read_jsonl("candidates")[0]["conscious_aperture"]["lease_expires_at"] @@ -259,6 +262,7 @@ def test_real_doorway_refuses_full_presentation_index_before_resume_or_new_claim def test_presentation_render_failure_and_same_turn_replay_do_not_mutate_ownership(tmp_path): store = _store(tmp_path / "render") store.append_jsonl("candidates", _advisory()) + retain_candidate_parent(store, "source_1") before = _ledger_bytes(store) failed = claim_conscious_aperture_for_presentation( store, @@ -269,7 +273,7 @@ def test_presentation_render_failure_and_same_turn_replay_do_not_mutate_ownershi render_context=lambda packet: (_ for _ in ()).throw(ValueError("render failed")), aperture_size=1, max_active_items=1, - instance_config={"allowed_surfaces": ["local"]}, + instance_config={"allowed_surfaces": ["local"], "max_sensitivity": "private"}, now=START, ) assert failed == {"success": False, "error": "presentation_render_failed"} @@ -284,7 +288,7 @@ def test_presentation_render_failure_and_same_turn_replay_do_not_mutate_ownershi render_context=lambda packet: "context", aperture_size=1, max_active_items=1, - instance_config={"allowed_surfaces": ["local"]}, + instance_config={"allowed_surfaces": ["local"], "max_sensitivity": "private"}, now=START, ) after_first = _ledger_bytes(store) @@ -297,7 +301,7 @@ def test_presentation_render_failure_and_same_turn_replay_do_not_mutate_ownershi render_context=lambda packet: "context", aperture_size=1, max_active_items=1, - instance_config={"allowed_surfaces": ["local"]}, + instance_config={"allowed_surfaces": ["local"], "max_sensitivity": "private"}, now="2026-09-15T11:01:00Z", ) assert first["presentation"]["action"] == "presentation_attempt_recorded" diff --git a/tests/test_core_latent_awareness.py b/tests/test_core_latent_awareness.py index 2cf0c2e..4201acd 100644 --- a/tests/test_core_latent_awareness.py +++ b/tests/test_core_latent_awareness.py @@ -4,6 +4,7 @@ import pytest from agent_sensorium import attention +from agent_sensorium.config import DisclosureVisibility from agent_sensorium.store import SensoriumStore POLICY = {"allowed_surfaces": ["local"], "max_sensitivity": "private"} @@ -23,7 +24,8 @@ def receipt(identifier="sth-a", action="hold", ts="2026-01-01T00:00:00Z"): def group(rows, decisions=None, **kwargs): return attention._ongoing_thread_group(rows, decisions or [], surface="local", config=POLICY, - previous_activation_at="2026-01-01T00:00:00Z", **kwargs) + previous_activation_at="2026-01-01T00:00:00Z", + disclosure=DisclosureVisibility([], rows, "local", POLICY), **kwargs) @pytest.mark.parametrize("field,value", [ diff --git a/tests/test_kanban_native_invariants.py b/tests/test_kanban_native_invariants.py index 07e09e9..424c95e 100644 --- a/tests/test_kanban_native_invariants.py +++ b/tests/test_kanban_native_invariants.py @@ -40,6 +40,7 @@ select_active_above_threshold, ) from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_event_signals from agent_sensorium.tools import ( handle_sensorium_conscious_claim, handle_sensorium_dispatch_once, @@ -397,10 +398,12 @@ def test_repeat_event_is_settled_via_apply_kanban_settlement(self, state_dir): first = self._hindsight_event( eid="evt_hp_1", summary="pending=300", fingerprint="hp_active", ) + retain_event_signals(SensoriumStore(instance="test", state_dir=state_dir), first) ingest_raw = handle_sensorium_ingest_event( event=first, instance="test", state_dir=state_dir, ) ingest = json.loads(ingest_raw) + assert ingest["success"] is True, ingest candidate_id = ingest["data"]["candidate_id"] store = SensoriumStore(instance="test", state_dir=state_dir) @@ -445,9 +448,11 @@ def test_settlement_is_idempotent_for_repeated_drop(self, state_dir): first = self._hindsight_event( eid="evt_idem", summary="pending=300", fingerprint="hp_idem", ) - handle_sensorium_ingest_event( + retain_event_signals(SensoriumStore(instance="test", state_dir=state_dir), first) + ingest = json.loads(handle_sensorium_ingest_event( event=first, instance="test", state_dir=state_dir, - ) + )) + assert ingest["success"] is True, ingest store = SensoriumStore(instance="test", state_dir=state_dir) s1 = apply_kanban_settlement( store, @@ -549,9 +554,11 @@ def test_planner_mints_intake_for_stale_above_threshold_hindsight_pressure(self, event = self._hindsight_event( eid="evt_stale", summary="pending=210 critical", fingerprint="hp_stale", ) + retain_event_signals(SensoriumStore(instance="test", state_dir=state_dir), event) ingest = json.loads( handle_sensorium_ingest_event(event=event, instance="test", state_dir=state_dir) ) + assert ingest["success"] is True, ingest candidate_id = ingest["data"]["candidate_id"] store = SensoriumStore(instance="test", state_dir=state_dir) @@ -575,9 +582,11 @@ def test_planner_coalesces_when_candidate_already_represented(self, state_dir): event = self._hindsight_event( eid="evt_rep", summary="pending=210", fingerprint="hp_rep", ) + retain_event_signals(SensoriumStore(instance="test", state_dir=state_dir), event) ingest = json.loads( handle_sensorium_ingest_event(event=event, instance="test", state_dir=state_dir) ) + assert ingest["success"] is True, ingest candidate_id = ingest["data"]["candidate_id"] store = SensoriumStore(instance="test", state_dir=state_dir) candidates = store.read_jsonl("candidates") @@ -640,9 +649,11 @@ def test_end_to_end_stale_candidate_minted_then_drops_out_after_settlement(self, event = self._hindsight_event( eid="evt_e2e", summary="pending=300 critical", fingerprint="hp_e2e", ) + retain_event_signals(SensoriumStore(instance="test", state_dir=state_dir), event) ingest = json.loads( handle_sensorium_ingest_event(event=event, instance="test", state_dir=state_dir) ) + assert ingest["success"] is True, ingest candidate_id = ingest["data"]["candidate_id"] store = SensoriumStore(instance="test", state_dir=state_dir) diff --git a/tests/test_p2_admission.py b/tests/test_p2_admission.py index b2ec67b..8e481f6 100644 --- a/tests/test_p2_admission.py +++ b/tests/test_p2_admission.py @@ -17,6 +17,7 @@ from agent_sensorium.store import SensoriumStore from agent_sensorium.subconscious import run_subconscious_advisory from agent_sensorium.tools import handle_sensorium_ingest_signal +from tests.test_source_closure_contract import reject_then_retain_historical ROOT = Path(__file__).parents[1] @@ -358,7 +359,7 @@ def test_04d_distinct_native_memory_item_set_preserves_first_consideration(tmp_p def test_04e_malformed_or_forged_native_memory_identity_is_suppressed(tmp_path): store = _store(tmp_path, "p4-memory-malformed") malformed = _memory_signal("bad-memory", "Malformed generated identity", ["memory-a", "memory-a"]) - _ingest(store, malformed) + reject_then_retain_historical(store, malformed) plan = build_admission_plan(store) assert plan["selection"] is None assert plan["suppressed_counts"] == {"malformed_memory_identity": 1} @@ -425,7 +426,17 @@ def test_07_transition_sequence_and_real_ingest_distinguish_later_episode_withou assert quiet is None assert transition["source_revision"] == heartbeat["source_revision"] == "1" assert recovered["source_revision"] == "2" and later_fault["source_revision"] == "3" - for signal in (transition, heartbeat, recovered, later_fault): + assert _ingest(store, transition)["duplicate"] is False + from unittest.mock import patch + before = {str(p.relative_to(store.root)): p.read_bytes() + for p in store.root.rglob("*") if p.is_file()} + from agent_sensorium import tools + with patch.object(tools, "observe_after_success", side_effect=AssertionError("No observer")), \ + patch.object(tools, "_apply_candidate_decay", side_effect=AssertionError("No decay")): + assert _ingest(store, heartbeat)["duplicate"] is True + assert {str(p.relative_to(store.root)): p.read_bytes() + for p in store.root.rglob("*") if p.is_file()} == before + for signal in (recovered, later_fault): assert _ingest(store, signal)["duplicate"] is False @@ -635,7 +646,7 @@ def test_matrix_08_malformed_partial_ids_never_claim_member_identity(tmp_path): malformed_sets = [["A", "A"], [""], ["x" * 513], [" A"], ["A", 7]] for index, item_ids in enumerate(malformed_sets): store = _store(tmp_path, f"matrix-malformed-{index}") - _ingest(store, _memory_signal(f"bad-{index}", "bad", item_ids)) + reject_then_retain_historical(store, _memory_signal(f"bad-{index}", "bad", item_ids)) plan = build_admission_plan(store) assert plan["selection"] is None assert plan["suppressed_counts"] == {"malformed_memory_identity": 1} diff --git a/tests/test_p2_review_regressions.py b/tests/test_p2_review_regressions.py index 2b019d9..d544635 100644 --- a/tests/test_p2_review_regressions.py +++ b/tests/test_p2_review_regressions.py @@ -498,8 +498,12 @@ def test_retry_updates_do_not_overwrite_corrupt_durable_owner_state(): ]) def test_nonqualifying_prior_support_cannot_promote_genuine_weak_source(tmp_path, prior): store = _store(tmp_path, f"negative-support-{prior['id']}") - first = _ingest(store, prior) - assert first["promoted"] is False + if prior["id"] == "malformed": + from tests.test_source_closure_contract import reject_then_retain_historical + reject_then_retain_historical(store, prior, promoted=False) + else: + first = _ingest(store, prior) + assert first["promoted"] is False incoming = _research("genuine", "genuine-item", strength=0.4, key="subject:support") result = _ingest(store, incoming) assert result["promoted"] is False diff --git a/tests/test_p3_composed_attention.py b/tests/test_p3_composed_attention.py index 368000d..bd5755f 100644 --- a/tests/test_p3_composed_attention.py +++ b/tests/test_p3_composed_attention.py @@ -16,6 +16,7 @@ from agent_sensorium.conscious_consumer import consume_conscious_advisory from agent_sensorium.plugin import register from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_candidate_parent ROOT = Path(__file__).resolve().parents[1] HERMES = ROOT.parent / "hermes" @@ -503,6 +504,7 @@ def test_foreground_unattended_race_has_one_canonical_winner_and_loser_cannot_mu state = tmp_path / "race" store = configure_store(state) store.append_jsonl("candidates", candidate("cand_race", "race-v1")) + retain_candidate_parent(store, "src-cand_race") ctx = PluginContext() register(ctx) foreground = ctx.hooks[0]( @@ -547,6 +549,7 @@ def test_foreground_unattended_race_has_one_canonical_winner_and_loser_cannot_mu reverse_state = tmp_path / "reverse-race" reverse = configure_store(reverse_state) reverse.append_jsonl("candidates", candidate("cand_reverse", "reverse-v1")) + retain_candidate_parent(reverse, "src-cand_reverse") clock["now"] = "2026-09-09T13:00:00Z" foreground_attempts = [] diff --git a/tests/test_p4_memory_identity_policy.py b/tests/test_p4_memory_identity_policy.py index c40bb8f..4be7fec 100644 --- a/tests/test_p4_memory_identity_policy.py +++ b/tests/test_p4_memory_identity_policy.py @@ -211,11 +211,12 @@ def forbidden(command, **kwargs): def test_malformed_explicit_memory_provenance_keeps_existing_reason(tmp_path): + from tests.test_source_closure_contract import reject_then_retain_historical store = SensoriumStore(instance="malformed", state_dir=str(tmp_path / "state")) store.ensure_dirs() signal = _unsupported_signal("malformed") signal["memory_provenance"] = {"provider": "hindsight", "bank_id": "b", "item_ids": ["A", "A"]} - _ingest(store, signal) + reject_then_retain_historical(store, signal) assert build_admission_plan(store)["suppressed_counts"] == {"malformed_memory_identity": 1} diff --git a/tests/test_pointers.py b/tests/test_pointers.py index 4c86684..3d6f129 100644 --- a/tests/test_pointers.py +++ b/tests/test_pointers.py @@ -10,6 +10,7 @@ ) from agent_sensorium.schemas import truncate_text from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_thread_parents def _thread(**overrides): @@ -78,6 +79,7 @@ def test_pointer_requires_allowed_surface(tmp_path): store = SensoriumStore(instance="test", state_dir=str(tmp_path)) store.ensure_dirs() store.append_jsonl("threads", _thread()) + retain_thread_parents(store) local = select_attention_pointer(store, surface="local") assert local["action"] == "pointer_available" @@ -93,6 +95,7 @@ def test_pre_llm_pointer_records_cooldown_receipt(tmp_path): store.ensure_dirs() _write_config(tmp_path, surfaces=["discord"]) store.append_jsonl("threads", _thread(allowed_surfaces=["discord"])) + retain_thread_parents(store) first = handle_pointer_pre_llm( instance="test", @@ -221,6 +224,7 @@ def test_thread_pointer_preferred_over_candidate_fallback(tmp_path): _write_config(tmp_path, surfaces=["discord"]) store.append_jsonl("candidates", _candidate(allowed_surfaces=["discord"])) store.append_jsonl("threads", _thread(allowed_surfaces=["discord"])) + retain_thread_parents(store) pointer = select_attention_pointer(store, surface="discord") assert pointer["action"] == "pointer_available" @@ -233,6 +237,7 @@ def test_pointer_preview_bypasses_cooldown_when_every_visible_thread_is_blocked( store.ensure_dirs() _write_config(tmp_path, surfaces=["discord"]) store.append_jsonl("threads", _thread(allowed_surfaces=["discord"])) + retain_thread_parents(store) first = handle_pointer_pre_llm( instance="test", @@ -254,6 +259,7 @@ def test_pointer_preview_can_still_fail_closed_on_cooldown_when_fallback_disable store.ensure_dirs() _write_config(tmp_path, surfaces=["discord"]) store.append_jsonl("threads", _thread(allowed_surfaces=["discord"])) + retain_thread_parents(store) first = handle_pointer_pre_llm( instance="test", @@ -573,6 +579,7 @@ def test_openable_thread_pointer_bypasses_non_openable_budget(tmp_path): store.ensure_dirs() _write_config(tmp_path, surfaces=["discord"]) store.append_jsonl("threads", _thread(allowed_surfaces=["discord"])) + retain_thread_parents(store) first = handle_pointer_pre_llm( instance="test", @@ -616,6 +623,7 @@ def test_config_allows_surface_returns_pointer(self, tmp_path): store.ensure_dirs() _write_config(tmp_path, surfaces=["local"]) store.append_jsonl("threads", _thread(allowed_surfaces=["local"])) + retain_thread_parents(store) result = select_attention_pointer(store, surface="local") assert result["action"] == "pointer_available" @@ -660,6 +668,7 @@ def test_local_still_works_with_default_config(self, tmp_path): store = SensoriumStore(instance="test", state_dir=str(tmp_path)) store.ensure_dirs() store.append_jsonl("threads", _thread(allowed_surfaces=["local"])) + retain_thread_parents(store) result = select_attention_pointer(store, surface="local") assert result["action"] == "pointer_available" diff --git a/tests/test_source_closure_contract.py b/tests/test_source_closure_contract.py new file mode 100644 index 0000000..5cce587 --- /dev/null +++ b/tests/test_source_closure_contract.py @@ -0,0 +1,214 @@ +"""Canonical disclosure and new-thread production contracts with synthetic stores.""" +import copy +import json + +import pytest + +from agent_sensorium import config, tools +from agent_sensorium.artifacts import store_artifact +from agent_sensorium.dispatcher import dispatch_once +from agent_sensorium.volunteer_cards import build_volunteer_cards +from tests.test_closed_visibility import _consumer_rows, _consumer_snapshot, _prepare_consumers +from tests.test_core_material_replay import store_at + + +def retain_candidate_parent(store, identifier): + """Complete an exact fictional parent without changing retained metadata.""" + if not any(row.get("id") == identifier for row in store.read_jsonl("candidates")): + store.append_jsonl("candidates", { + "id": identifier, "status": "archived", "pressure": 0, + "kind": "design_decision", "summary": "Synthetic retained parent", + "sensitivity": "private", "allowed_surfaces": ["local", "discord", "dashboard"], + }) + + +def retain_thread_parents(store): + for row in store.read_jsonl("threads"): + if "origin_candidate_id" in row: + retain_candidate_parent(store, row["origin_candidate_id"]) + + +def reject_then_retain_historical(store, signal, *, promoted=True): + """Separate rejected producer ingress from malformed historical admission.""" + from unittest.mock import patch + from agent_sensorium.gate import event_to_candidate, promote_signal_to_event + from agent_sensorium.schemas import normalize_signal + before = _consumer_snapshot(store) + with patch.object(tools, "observe_after_success", side_effect=AssertionError("No observer")), \ + patch.object(tools, "_apply_candidate_decay", side_effect=AssertionError("No decay")): + rejected = json.loads(tools.handle_sensorium_ingest_signal( + signal=signal, instance=store.instance, state_dir=str(store.root))) + assert rejected["success"] is False + assert rejected["error"] == "invalid_ingress_claim" + assert _consumer_snapshot(store) == before + assert all(store.read_jsonl(name) == [] for name in ("signals", "events", "candidates", "decisions")) + retained = normalize_signal(signal) + store.append_jsonl("signals", retained) + if promoted: + event = promote_signal_to_event(retained) + store.append_jsonl("events", event) + store.append_jsonl("candidates", event_to_candidate(event)) + + +def retain_event_signals(store, event): + from agent_sensorium.schemas import normalize_signal + store.ensure_dirs() + for identifier in event["source_signal_ids"]: + store.append_jsonl("signals", normalize_signal({ + "id": identifier, "sensor": "synthetic.local", "source": "local", + "kind": event["kind"], "summary": event["summary"], + "strength_hint": event["strength"], "correlation_keys": event["correlation_keys"], + "sensitivity": event["sensitivity"], "allowed_surfaces": event["allowed_surfaces"], + })) + + +@pytest.mark.parametrize("shape", ["root", "nested", "list"]) +@pytest.mark.parametrize("action", ["DROP", "SAVE", "CREATE_CONSCIOUS_TASK"]) +def test_empty_link_does_not_authorize_rationale(tmp_path, monkeypatch, shape, action): + store = store_at(tmp_path) + marker = "Synthetic withheld rationale" + empty = {"source_candidate_ids": []} + receipt = {"type": "subconscious.advisory", "output_action": action, "reason": marker} + receipt.update(empty if shape == "root" else {"metadata": empty if shape == "nested" else [empty]}) + store.append_jsonl("decisions", receipt) + store.write_state({"last_dispatch_result": receipt}) + _prepare_consumers(store, monkeypatch, config._validate_config({})) + before = _consumer_snapshot(store) + result = tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root)) + assert marker not in result + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("mode", ["absent", "corrupt", "nonmapping", "directory", "unreadable"]) +def test_config_presence_controls_real_disclosure(tmp_path, monkeypatch, mode): + store = store_at(tmp_path) + marker = "Synthetic retained subject" + candidate, thread = _consumer_rows(marker) + store.append_jsonl("candidates", candidate) + store.append_jsonl("threads", thread) + path = store.root / "instance.config.json" + if mode == "directory": + path.mkdir() + elif mode != "absent": + path.write_text("{broken" if mode == "corrupt" else "[]" if mode == "nonmapping" else "{}") + if mode == "unreadable": + original = type(path).read_text + def denied(self, *args, **kwargs): + if self == path: + raise PermissionError("Synthetic denial") + return original(self, *args, **kwargs) + monkeypatch.setattr(type(path), "read_text", denied) + with store.candidate_transaction(): + pass + before = _consumer_snapshot(store) + policy, diagnostic = config.load_instance_config(state_dir=str(store.root)) + result = tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root)) + assert (marker in result) == (mode == "absent") + assert policy.get("_visibility_invalid", False) == (mode != "absent") + assert diagnostic["allowed_surfaces"] == ["local"] + assert "Synthetic denial" not in json.dumps(diagnostic) + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("parent", ["visible", "hidden", "missing", "duplicate"]) +def test_real_candidate_artifact_preserves_placeholders(tmp_path, monkeypatch, parent): + store = store_at(tmp_path) + candidate, _ = _consumer_rows("Synthetic artifact parent") + candidate["status"] = "archived" + if parent == "hidden": + candidate["allowed_surfaces"] = [] + if parent != "missing": + store.append_jsonl("candidates", candidate) + if parent == "duplicate": + store.append_jsonl("candidates", copy.deepcopy(candidate)) + result = store_artifact(store, kind="text", ref_path="synthetic-note.txt", + source_candidate_id=candidate["id"], why_created="Synthetic note") + assert result["success"] + artifact = store.read_jsonl("artifacts")[0] + assert artifact["source_refs"] == {"thread_id": "", "candidate_id": candidate["id"], "action_id": ""} + _prepare_consumers(store, monkeypatch, config._validate_config({})) + before = _consumer_snapshot(store) + cards = build_volunteer_cards(store, surface="local", limit=10) + assert any(c["card_type"] == "artifact" for c in cards) == (parent == "visible") + assert _consumer_snapshot(store) == before + + +@pytest.mark.parametrize("bad", [None, False, 0, [], {}, "nonexistent", ""]) +def test_arbitrary_thread_identifiers_are_not_placeholders(tmp_path, bad): + candidate, _ = _consumer_rows("Synthetic parent") + disclosure = config.DisclosureVisibility([candidate], [], "local", config._validate_config({})) + if bad is not None: + assert not disclosure.linked({"candidate_id": candidate["id"], "thread_id": bad}) + if bad != "": + assert not disclosure.linked({"source_refs": {"candidate_id": candidate["id"], "thread_id": bad}}) + + +@pytest.mark.parametrize("mode", ["complete", "missing_visibility", "hidden", "duplicate", "dangling", "deny_all", + "missing_id", "missing_sensitivity", "invalid_config", "invalid_operational"]) +def test_dispatcher_produces_only_disclosable_threads(tmp_path, mode): + store = store_at(tmp_path) + candidate, _ = _consumer_rows("Synthetic production source") + candidate.update(status="candidate", pressure=0.9) + if mode == "missing_visibility": + candidate.pop("allowed_surfaces") + elif mode == "hidden": + candidate["allowed_surfaces"] = [] + elif mode == "dangling": + candidate["source_candidate_ids"] = ["nonexistent"] + elif mode == "missing_id": + candidate.pop("id") + elif mode == "missing_sensitivity": + candidate.pop("sensitivity") + store.append_jsonl("candidates", candidate) + if mode == "duplicate": + store.append_jsonl("candidates", {**candidate, "status": "archived"}) + if mode == "deny_all": + (store.root / "instance.config.json").write_text(json.dumps({"allowed_surfaces": []})) + elif mode == "invalid_config": + (store.root / "instance.config.json").write_text("{broken") + before = store.read_jsonl("candidates") + dispatch_config: dict = {"legacy_thread_dispatch_enabled": True} + if mode == "invalid_operational": + dispatch_config["operational_pointer"] = {"enabled": True, "sensitivity": "invalid"} + result = dispatch_once(store, dry_run=False, config=dispatch_config) + assert store.read_jsonl("candidates") == before + assert (result["action"] == "promoted") == (mode == "complete") + assert len(store.read_jsonl("threads")) == (1 if mode == "complete" else 0) + assert len(store.read_jsonl("decisions")) == (1 if mode == "complete" else 0) + assert store.read_state()["budgets"]["dispatch"]["remaining"] == (9 if mode == "complete" else 10) + if mode == "complete": + opened = json.loads(tools.handle_sensorium_thread_open( + thread_id=result["thread_id"], instance=store.instance, state_dir=str(store.root))) + assert opened["success"] + assert "Synthetic production source" in json.dumps(opened) + retained = store.read_jsonl("threads")[0] + assert retained["origin_candidate_id"] == candidate["id"] + store.rewrite_jsonl("candidates", [{**candidate, "status": "archived"}]) + assert json.loads(tools.handle_sensorium_thread_open( + thread_id=result["thread_id"], instance=store.instance, state_dir=str(store.root)))["success"] + store.rewrite_jsonl("candidates", [{k: v for k, v in candidate.items() if k != "sensitivity"}]) + assert not json.loads(tools.handle_sensorium_thread_open( + thread_id=result["thread_id"], instance=store.instance, state_dir=str(store.root)))["success"] + assert store.read_jsonl("threads")[0] == retained + + +def test_dispatcher_skips_ineligible_pressure_and_cannot_broaden_source(tmp_path): + store = store_at(tmp_path) + candidate, _ = _consumer_rows("Synthetic low-pressure source") + candidate.update(status="candidate", pressure=0.7, allowed_surfaces=["local"]) + high = {**candidate, "id": "candidate-high", "pressure": 0.99, "allowed_surfaces": []} + store.append_jsonl("candidates", high) + store.append_jsonl("candidates", candidate) + before = store.read_jsonl("candidates") + result = dispatch_once(store, dry_run=False, config={ + "legacy_thread_dispatch_enabled": True, + "operational_pointer": {"enabled": True, "surfaces": ["discord"], "sensitivity": "private"}, + "allowed_surfaces": ["local", "discord"], "max_sensitivity": "private"}) + assert result["action"] == "promoted" + assert result["candidate_id"] == candidate["id"] + assert store.read_jsonl("candidates") == before + (store.root / "instance.config.json").write_text(json.dumps({"allowed_surfaces": ["local", "discord"]})) + assert json.loads(tools.handle_sensorium_thread_open(thread_id=result["thread_id"], + instance=store.instance, state_dir=str(store.root), surface="local"))["success"] + assert not json.loads(tools.handle_sensorium_thread_open(thread_id=result["thread_id"], + instance=store.instance, state_dir=str(store.root), surface="discord"))["success"] diff --git a/tests/test_thread_lifecycle.py b/tests/test_thread_lifecycle.py index d2064c1..0db600e 100644 --- a/tests/test_thread_lifecycle.py +++ b/tests/test_thread_lifecycle.py @@ -10,6 +10,7 @@ handle_sensorium_thread_update, ) from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_thread_parents def _thread(**overrides): @@ -53,6 +54,7 @@ def test_thread_open_returns_compact_capsule_when_surface_allowed(tmp_path): store.ensure_dirs() _write_config(tmp_path, surfaces=["local", "discord"]) store.append_jsonl("threads", _thread()) + retain_thread_parents(store) result = json.loads( handle_sensorium_thread_open( @@ -88,6 +90,7 @@ def test_thread_update_closes_thread_and_removes_pointer_eligibility(tmp_path): store.ensure_dirs() _write_config(tmp_path, surfaces=["local", "discord"]) store.append_jsonl("threads", _thread()) + retain_thread_parents(store) before = json.loads( handle_sensorium_attention_pointer( @@ -329,6 +332,7 @@ def test_config_allows_surface_permits_open(self, tmp_path): store.ensure_dirs() _write_config(tmp_path, surfaces=["local"]) store.append_jsonl("threads", _thread(allowed_surfaces=["local"])) + retain_thread_parents(store) result = json.loads(handle_sensorium_thread_open( instance="test", state_dir=str(tmp_path), @@ -370,6 +374,7 @@ def test_local_works_with_default_config(self, tmp_path): store = SensoriumStore(instance="test", state_dir=str(tmp_path)) store.ensure_dirs() store.append_jsonl("threads", _thread(allowed_surfaces=["local"])) + retain_thread_parents(store) result = json.loads(handle_sensorium_thread_open( instance="test", state_dir=str(tmp_path), diff --git a/tests/test_thread_service.py b/tests/test_thread_service.py index ba86b32..ad866c6 100644 --- a/tests/test_thread_service.py +++ b/tests/test_thread_service.py @@ -9,6 +9,7 @@ handle_sensorium_thread_update, ) from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_thread_parents def _thread(tmp_path=None, store=None, **overrides): @@ -52,6 +53,8 @@ def _candidate(**overrides): "kind": "design_decision", "pressure": 0.8, "summary": "Service test candidate", + "sensitivity": "private", + "allowed_surfaces": ["local"], "correlation_keys": ["test-key"], "fingerprint": "fp_service_test", "updated_at": "2026-05-20T10:00:00Z", @@ -532,6 +535,7 @@ def test_hold_reason_preserved_in_thread_open(tmp_path): source_refs=[{"type": "event", "id": "evt_1"}], ), ) + retain_thread_parents(store) result = json.loads( handle_sensorium_thread_open( diff --git a/tests/test_volunteer_cards.py b/tests/test_volunteer_cards.py index 73051ef..a47ccd7 100644 --- a/tests/test_volunteer_cards.py +++ b/tests/test_volunteer_cards.py @@ -178,6 +178,7 @@ def test_volunteer_cards_are_compact_and_do_not_leak_secret_or_body_fields(tmp_p def test_artifact_card_has_parent_reference_id_when_selected(tmp_path): store = _store(tmp_path) store.append_jsonl("artifacts", _artifact("art_only", source_refs={"thread_id": "sth_parent"})) + store.append_jsonl("threads", _thread("sth_parent", status="archived")) cards = build_volunteer_cards(store, surface="local", limit=3) From 30413b69108ff9effdb87c3e41f68cad9f07b8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 15:57:25 +0200 Subject: [PATCH 13/16] Keep dispatch disclosure bounded by retained policy --- agent_sensorium/dispatcher.py | 38 +++++- tests/test_dispatcher_canonical_policy.py | 155 ++++++++++++++++++++++ 2 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tests/test_dispatcher_canonical_policy.py diff --git a/agent_sensorium/dispatcher.py b/agent_sensorium/dispatcher.py index c3c9230..a8b7024 100644 --- a/agent_sensorium/dispatcher.py +++ b/agent_sensorium/dispatcher.py @@ -19,7 +19,7 @@ from .gate import is_feedback_self_loop from .config import DisclosureVisibility, load_instance_config from .actuator_contracts import mediated_artifact_review_contract -from .schemas import new_id, truncate_text, utc_now_iso +from .schemas import SENSITIVITY_RANK, decode_visibility, new_id, truncate_text, utc_now_iso from .store import SensoriumStore STATE_VERSION = 1 @@ -77,6 +77,25 @@ def _merged_config(config: dict | None = None) -> dict: return cfg +def _disclosure_policy(retained: dict, overlay: dict | None) -> dict: + """Execution overrides can restrict, never replace retained authority.""" + policy = {key: retained[key] for key in ("allowed_surfaces", "max_sensitivity")} + canonical = decode_visibility(retained, sensitivity_key="max_sensitivity") + restricted = decode_visibility( + {**policy, **{key: value for key, value in (overlay or {}).items() + if key in ("allowed_surfaces", "max_sensitivity", "_visibility_invalid")}}, + sensitivity_key="max_sensitivity", + ) + if canonical is None or restricted is None: + policy["_visibility_invalid"] = True + return policy + maximum, surfaces = canonical + overlay_maximum, overlay_surfaces = restricted + policy["allowed_surfaces"] = [surface for surface in surfaces if surface in overlay_surfaces] + policy["max_sensitivity"] = min((maximum, overlay_maximum), key=SENSITIVITY_RANK.__getitem__) + return policy + + def _parse_utc(ts: str | None) -> datetime | None: if not ts: return None @@ -396,6 +415,7 @@ def _dispatch_core( *, dry_run: bool, cfg: dict, + disclosure_policy: dict, budgets: dict, ) -> dict: candidates = store.read_jsonl("candidates") @@ -403,9 +423,9 @@ def _dispatch_core( eligible = candidates if not dry_run and cfg.get("legacy_thread_dispatch_enabled"): # Keep the full canonical view for uniqueness, including terminal rows. - surfaces = cfg.get("allowed_surfaces") + surfaces = disclosure_policy.get("allowed_surfaces") surfaces = surfaces if isinstance(surfaces, list) else [] - disclosures = [DisclosureVisibility(candidates, existing_threads, surface, cfg) + disclosures = [DisclosureVisibility(candidates, existing_threads, surface, disclosure_policy) for surface in surfaces] eligible = [c for c in candidates if any( disclosure.subject("candidate", c.get("id")) for disclosure in disclosures @@ -413,7 +433,7 @@ def _dispatch_core( usable = [] for candidate in eligible: preview = candidate_to_thread(candidate, cfg) - if any(DisclosureVisibility(candidates, [*existing_threads, preview], surface, cfg) + if any(DisclosureVisibility(candidates, [*existing_threads, preview], surface, disclosure_policy) .subject("thread", preview["id"]) for surface in surfaces): usable.append(candidate) eligible = usable @@ -497,12 +517,14 @@ def dispatch_once( config: dict | None = None, ) -> dict: instance_config, _ = load_instance_config(state_dir=str(store.root)) + disclosure_policy = _disclosure_policy(instance_config, config) cfg = _merged_config({**instance_config, **(config or {})}) store.ensure_dirs() budgets = _budget_state(store, cfg) if dry_run: - result = _dispatch_core(store, dry_run=True, cfg=cfg, budgets=budgets) + result = _dispatch_core(store, dry_run=True, cfg=cfg, + disclosure_policy=disclosure_policy, budgets=budgets) _write_latest( store, result=result, @@ -512,7 +534,8 @@ def dispatch_once( return result if not cfg.get("legacy_thread_dispatch_enabled"): - result = _dispatch_core(store, dry_run=False, cfg=cfg, budgets=budgets) + result = _dispatch_core(store, dry_run=False, cfg=cfg, + disclosure_policy=disclosure_policy, budgets=budgets) _write_latest( store, result=result, @@ -533,7 +556,8 @@ def dispatch_once( return result try: - result = _dispatch_core(store, dry_run=False, cfg=cfg, budgets=budgets) + result = _dispatch_core(store, dry_run=False, cfg=cfg, + disclosure_policy=disclosure_policy, budgets=budgets) finally: _release_dispatch_lock(store, lock.get("owner", "")) diff --git a/tests/test_dispatcher_canonical_policy.py b/tests/test_dispatcher_canonical_policy.py new file mode 100644 index 0000000..0449ce8 --- /dev/null +++ b/tests/test_dispatcher_canonical_policy.py @@ -0,0 +1,155 @@ +"""Synthetic execution overlays must only narrow retained disclosure authority.""" + +import copy +import json + +import pytest + +from agent_sensorium.dispatcher import dispatch_once +from agent_sensorium.store import SensoriumStore +from agent_sensorium.tools import handle_sensorium_dispatch_once, handle_sensorium_thread_open + + +def _seed(tmp_path): + store = SensoriumStore(instance="synthetic", state_dir=str(tmp_path / "instance")) + store.ensure_dirs() + candidate = { + "id": "candidate-synthetic", "status": "candidate", "pressure": 0.9, + "kind": "design_decision", "summary": "Synthetic retained source", + "sensitivity": "private", "allowed_surfaces": ["local"], + } + store.append_jsonl("candidates", candidate) + return store, candidate + + +def _dispatch(store, overlay, entry): + if entry == "direct": + return dispatch_once(store, dry_run=False, config=overlay) + return json.loads(handle_sensorium_dispatch_once( + instance=store.instance, state_dir=str(store.root), dry_run=False, + config=overlay))["data"] + + +def _open(store, result): + return json.loads(handle_sensorium_thread_open( + thread_id=result.get("thread_id", "missing"), instance=store.instance, + state_dir=str(store.root))) + + +@pytest.mark.parametrize("entry", ["direct", "handler"]) +@pytest.mark.parametrize("retained", [ + {"allowed_surfaces": []}, + {"allowed_surfaces": ["discord"]}, + {"max_sensitivity": "local_only"}, + {"allowed_surfaces": None}, + {"max_sensitivity": "invalid"}, + "{broken", + "[]", +]) +def test_root_override_cannot_create_unopenable_thread(tmp_path, entry, retained): + store, _ = _seed(tmp_path) + path = store.root / "instance.config.json" + path.write_text(retained if isinstance(retained, str) else json.dumps(retained)) + config_before = path.read_bytes() + candidate_before = store.paths["candidates"].read_bytes() + overlay = {"legacy_thread_dispatch_enabled": True, "allowed_surfaces": ["local"], + "max_sensitivity": "private", "_visibility_invalid": False} + overlay_before = copy.deepcopy(overlay) + result = _dispatch(store, overlay, entry) + actual = { + "action": result["action"], "opened": _open(store, result)["success"], + "threads": len(store.read_jsonl("threads")), + "decisions": len(store.read_jsonl("decisions")), + "remaining": store.read_state()["budgets"]["dispatch"]["remaining"], + } + assert actual == {"action": "no_candidate", "opened": False, "threads": 0, + "decisions": 0, "remaining": 10} + assert store.paths["candidates"].read_bytes() == candidate_before + assert path.read_bytes() == config_before + assert overlay == overlay_before + + +@pytest.mark.parametrize("entry", ["direct", "handler"]) +@pytest.mark.parametrize("restriction", [ + {"allowed_surfaces": []}, {"allowed_surfaces": ["discord"]}, + {"max_sensitivity": "local_only"}, + {"allowed_surfaces": None}, {"allowed_surfaces": "local"}, + {"allowed_surfaces": ["local", ""]}, {"allowed_surfaces": [1]}, + {"max_sensitivity": None}, {"max_sensitivity": "invalid"}, + {"_visibility_invalid": True}, +]) +def test_present_overlay_restrictions_never_add_authority(tmp_path, entry, restriction): + store, _ = _seed(tmp_path) + path = store.root / "instance.config.json" + path.write_text(json.dumps({"allowed_surfaces": ["local", "discord"], + "max_sensitivity": "private"})) + before = (path.read_bytes(), store.paths["candidates"].read_bytes()) + result = _dispatch(store, {"legacy_thread_dispatch_enabled": True, **restriction}, entry) + assert result["action"] == "no_candidate" + assert not _open(store, result)["success"] + assert store.read_jsonl("threads") == [] + assert store.read_jsonl("decisions") == [] + assert store.read_state()["budgets"]["dispatch"]["remaining"] == 10 + assert (path.read_bytes(), store.paths["candidates"].read_bytes()) == before + + +@pytest.mark.parametrize("entry", ["direct", "handler"]) +@pytest.mark.parametrize("retained", [None, {}, {"allowed_surfaces": ["local", "discord"], + "max_sensitivity": "public_safe"}]) +@pytest.mark.parametrize("restriction", [{}, {"allowed_surfaces": ["local"]}, + {"max_sensitivity": "private"}]) +def test_complete_source_and_lower_pressure_remain_openable(tmp_path, entry, retained, restriction): + store, candidate = _seed(tmp_path) + path = store.root / "instance.config.json" + if retained is not None: + path.write_text(json.dumps(retained)) + config_before = path.read_bytes() if path.exists() else None + store.append_jsonl("candidates", {**candidate, "id": "candidate-hidden", + "pressure": 0.99, "allowed_surfaces": []}) + before = store.paths["candidates"].read_bytes() + result = _dispatch(store, {"legacy_thread_dispatch_enabled": True, **restriction}, entry) + assert result["action"] == "promoted" + assert result["candidate_id"] == candidate["id"] + assert _open(store, result)["success"] + assert len(store.read_jsonl("threads")) == 1 + assert len(store.read_jsonl("decisions")) == 1 + assert store.read_state()["budgets"]["dispatch"]["remaining"] == 9 + assert store.paths["candidates"].read_bytes() == before + assert (path.read_bytes() if path.exists() else None) == config_before + thread = store.read_jsonl("threads")[0] + assert thread["origin_candidate_id"] == candidate["id"] + store.rewrite_jsonl("candidates", [{**candidate, "status": "archived"}]) + assert _open(store, result)["success"] + store.rewrite_jsonl("candidates", [{k: v for k, v in candidate.items() if k != "sensitivity"}]) + assert not _open(store, result)["success"] + assert store.read_jsonl("threads")[0] == thread + + +@pytest.mark.parametrize("control,action,remaining", [ + ({}, "legacy_dispatch_disabled", 10), + ({"legacy_thread_dispatch_enabled": True, "thresholds": {"dispatch_pressure": 0.95}}, + "no_candidate", 10), + ({"legacy_thread_dispatch_enabled": True, "budgets": {"dispatch": {"capacity": 0}}}, + "budget_exhausted", 0), +]) +def test_ordinary_execution_controls_remain_effective(tmp_path, control, action, remaining): + store, _ = _seed(tmp_path) + result = _dispatch(store, control, "handler") + assert result["action"] == action + assert store.read_jsonl("threads") == [] + assert store.read_jsonl("decisions") == [] + assert store.read_state()["budgets"]["dispatch"]["remaining"] == remaining + + +def test_prospective_thread_obeys_same_narrowing_as_candidate(tmp_path): + store, candidate = _seed(tmp_path) + candidate["sensitivity"] = "local_only" + store.rewrite_jsonl("candidates", [candidate]) + result = _dispatch(store, { + "legacy_thread_dispatch_enabled": True, "max_sensitivity": "local_only", + "operational_pointer": {"enabled": True, "sensitivity": "private"}, + }, "handler") + assert result["action"] == "no_candidate" + assert store.read_jsonl("threads") == [] + assert store.read_jsonl("decisions") == [] + assert store.read_state()["budgets"]["dispatch"]["remaining"] == 10 From 1e56655ba72d44b38d6281d8a41bd072143ae7f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 17:52:31 +0200 Subject: [PATCH 14/16] Validate retained lineage before policy mutation --- agent_sensorium/tools.py | 212 +++++++++++-------- tests/test_retained_mutation_preflight.py | 239 ++++++++++++++++++++++ 2 files changed, 365 insertions(+), 86 deletions(-) create mode 100644 tests/test_retained_mutation_preflight.py diff --git a/agent_sensorium/tools.py b/agent_sensorium/tools.py index 47ee84a..997c47a 100644 --- a/agent_sensorium/tools.py +++ b/agent_sensorium/tools.py @@ -27,6 +27,7 @@ from .pointers import select_attention_pointer from .volunteer_cards import build_volunteer_cards from .schemas import ( + decode_visibility, intersect_allowed_surfaces, merge_sensitivity, normalize_signal, @@ -346,6 +347,16 @@ def _stored_candidate_fingerprint(candidate: dict) -> str: return candidate.get("fingerprint") or candidate_fingerprint(candidate) +def _retained_refs(row: dict, key: str) -> list[str]: + """Absent historical refs are empty; present authority is never coerced.""" + values = row.get(key, []) + if (not isinstance(values, list) + or any(not isinstance(value, str) or not value for value in values) + or len(set(values)) != len(values)): + raise ValueError("ambiguous_ingress_lineage") + return values + + def _candidate_for_event_id(candidates: list[dict], event_id: str) -> dict | None: """Resolve source ownership, not advisory evidence membership. @@ -357,13 +368,10 @@ def _candidate_for_event_id(candidates: list[dict], event_id: str) -> dict | Non resolved: dict[str, set[str]] = {} visiting: set[str] = set() - def refs(row, key): - values = row.get(key, []) - if (not isinstance(values, list) - or any(not isinstance(value, str) or not value for value in values) - or len(set(values)) != len(values)): - raise ValueError("ambiguous_ingress_lineage") - return values + refs = _retained_refs + for row in candidates: + refs(row, "event_ids") + refs(row, "source_candidate_ids") def roots(identifier): found = lookup_record(index, identifier) @@ -407,7 +415,7 @@ def roots(identifier): resolved[identifier] = owners if sources else {identifier} return resolved[identifier] - matches = [row for row in candidates if event_id in (row.get("event_ids") or [])] + matches = [row for row in candidates if event_id in refs(row, "event_ids")] if not matches: return None owners = set() @@ -418,7 +426,7 @@ def roots(identifier): while True: descendants = [row for row in candidates if any(isinstance(ref, str) and ref in resolved - for ref in (row.get("source_candidate_ids") or []))] + for ref in refs(row, "source_candidate_ids"))] prior = len(resolved) for row in descendants: roots(row.get("id")) @@ -477,7 +485,7 @@ def equivalent(row): def _find_event_for_signal(events: list[dict], signal_id: str) -> dict | None: return _unique_match(events, [row for row in events - if signal_id in (row.get("source_signal_ids") or [])]) + if signal_id in _retained_refs(row, "source_signal_ids")]) GENERIC_CORRELATION_KEYS = {"active-session"} @@ -515,6 +523,11 @@ def _find_related_candidate( if incoming_identity is None: return None incoming_item = incoming_identity[0] + for row in events: + _retained_refs(row, "source_signal_ids") + for row in candidates: + _retained_refs(row, "event_ids") + _retained_refs(row, "source_candidate_ids") event_index = record_index(events) candidate_index = record_index(candidates) matches = [] @@ -525,7 +538,7 @@ def _find_related_candidate( continue if candidate.get("kind") != event.get("kind"): continue - candidate_event_ids = list(candidate.get("event_ids") or []) + candidate_event_ids = _retained_refs(candidate, "event_ids") prior_events = [lookup_record(event_index, event_id).row for event_id in candidate_event_ids] if not prior_events or any(not isinstance(prior_event, dict) for prior_event in prior_events): continue @@ -537,7 +550,11 @@ def _find_related_candidate( continue if {identity[0] for identity in prior_identities if identity is not None} == {incoming_item}: matches.append(candidate) - return _unique_match(candidates, matches) + related = _unique_match(candidates, matches) + if related is not None: + _retained_mutation_closure(store, signals=signals, events=events, candidates=candidates, + candidate_ids=[related["id"]]) + return related def _read_pruned_sensor_policy( @@ -611,52 +628,93 @@ def _promoted_signal_existing_result(signal: dict, events: list[dict], candidate return result -@_candidate_transactional -def _narrow_replay_visibility( - store: SensoriumStore, incoming: dict, *, signal_ids=(), event_ids=(), check_only=False, -) -> None: - """Restrict retained lineage for future disclosure, never replay its effects. +def _retained_mutation_closure( + store, *, signals=None, events=None, candidates=None, + signal_ids=(), event_ids=(), candidate_ids=(), +): + """Validate typed graph evidence and all affected authority before effects. - Delivered history is not revoked. Equal/wider metadata produces no writes; - the existing sensitivity/surface merge owners only permit narrowing. + Reference shape is checked before selection: malformed evidence cannot prove + that a row is unrelated. Visibility, by contrast, is checked only on the + actual policy mutation selection, not identity-only dependencies. That selection is + also the mutation selection, never a second traversal of raw retained refs. """ - signals = store.read_jsonl("signals") - events = store.read_jsonl("events") - candidates = store.read_jsonl("candidates") - signal_ids, event_ids = set(signal_ids), set(event_ids) - - # Validate the entire reachable lineage before mutating even in-memory rows. - signal_index, event_index = record_index(signals), record_index(events) - candidate_index = record_index(candidates) - for identifier in signal_ids: - if lookup_record(signal_index, identifier).state != "unique": - raise ValueError("ambiguous_ingress_lineage") - for row in events: - if row.get("id") in event_ids or signal_ids.intersection(row.get("source_signal_ids") or []): - if (lookup_record(event_index, row.get("id")).state != "unique" - or classify_event_claim(store.instance, row, signals=signals).kind == "malformed"): - raise ValueError("ambiguous_ingress_lineage") + signals = store.read_jsonl("signals") if signals is None else signals + events = store.read_jsonl("events") if events is None else events + candidates = store.read_jsonl("candidates") if candidates is None else candidates + threads = store.read_jsonl("threads") + event_refs = [_retained_refs(row, "source_signal_ids") for row in events] + candidate_events = [_retained_refs(row, "event_ids") for row in candidates] + candidate_sources = [_retained_refs(row, "source_candidate_ids") for row in candidates] + signal_ids = set(_retained_refs({"refs": list(signal_ids)}, "refs")) + event_ids = set(_retained_refs({"refs": list(event_ids)}, "refs")) + coalescing_ids = set(_retained_refs({"refs": list(candidate_ids)}, "refs")) + affected = set(coalescing_ids) + for row, refs in zip(events, event_refs): + if signal_ids.intersection(refs): event_ids.add(row.get("id")) - affected = {row.get("id") for row in candidates - if event_ids.intersection(row.get("event_ids") or [])} + for row, refs in zip(candidates, candidate_events): + if event_ids.intersection(refs): + affected.add(row.get("id")) while True: - descendants = {row.get("id") for row in candidates - if affected.intersection(row.get("source_candidate_ids") or [])} + descendants = {row.get("id") for row, refs in zip(candidates, candidate_sources) + if affected.intersection(refs)} if descendants.issubset(affected): break affected.update(descendants) + + indexes = {"signals": record_index(signals), "events": record_index(events), + "candidates": record_index(candidates), "threads": record_index(threads)} + + def require(stream, identifier, *, policy=False): + found = lookup_record(indexes[stream], identifier) + if (found.state != "unique" or found.row is None + or (policy and decode_visibility(found.row) is None)): + raise ValueError("ambiguous_ingress_lineage") + return found.row + + # Evidence must resolve completely without conferring disclosure authority. + dependencies = set(event_ids) for identifier in affected: - row = lookup_record(candidate_index, identifier).row - if row is None or any(lookup_record(event_index, ref).state != "unique" - for ref in row.get("event_ids") or []): + row = require("candidates", identifier) + dependencies.update(_retained_refs(row, "event_ids")) + for source in _retained_refs(row, "source_candidate_ids"): + require("candidates", source) + for identifier in dependencies: + row = require("events", identifier) + if classify_event_claim(store.instance, row, signals=signals).kind == "malformed": raise ValueError("ambiguous_ingress_lineage") - threads = store.read_jsonl("threads") - thread_index = record_index(threads) - if any(lookup_record(thread_index, row.get("id")).state != "unique" - for row in threads if row.get("origin_candidate_id") in affected): - raise ValueError("ambiguous_ingress_lineage") + for ref in _retained_refs(row, "source_signal_ids"): + require("signals", ref) + _candidate_for_event_id(candidates, identifier) + selected_candidates = coalescing_ids if coalescing_ids else affected + for identifier in signal_ids: + require("signals", identifier, policy=True) + selected_threads = [] + for row in threads: + origin = row.get("origin_candidate_id") + if not coalescing_ids and isinstance(origin, str) and origin in affected: + require("threads", row.get("id"), policy=True) + selected_threads.append(row) + return [ + ("threads", threads, selected_threads), + ("candidates", candidates, [require("candidates", ref, policy=True) + for ref in selected_candidates]), + ("events", events, [require("events", ref, policy=True) for ref in event_ids]), + ("signals", signals, [require("signals", ref, policy=True) for ref in signal_ids]), + ] + + +@_candidate_transactional +def _narrow_replay_visibility( + store: SensoriumStore, incoming: dict, *, signal_ids=(), event_ids=(), check_only=False, + closure=None, +): + """Restrict validated retained lineage, never heal authority or replay effects.""" + if closure is None: + closure = _retained_mutation_closure(store, signal_ids=signal_ids, event_ids=event_ids) if check_only: - return + return closure def narrow(row): sensitivity = merge_sensitivity([ @@ -672,38 +730,12 @@ def narrow(row): row["sensitivity"], row["allowed_surfaces"] = sensitivity, surfaces return True - changed_signals = False - for row in signals: - if row.get("id") in signal_ids: - changed_signals = narrow(row) or changed_signals - changed_events = False - for row in events: - if row.get("id") in event_ids or signal_ids.intersection(row.get("source_signal_ids") or []): - event_ids.add(row.get("id")) - changed_events = narrow(row) or changed_events - affected = {row.get("id") for row in candidates if event_ids.intersection(row.get("event_ids") or [])} - # Advisory descendants can retain source IDs without retaining event IDs. - while True: - descendants = {row.get("id") for row in candidates - if affected.intersection(row.get("source_candidate_ids") or [])} - if descendants.issubset(affected): - break - affected.update(descendants) - changed_candidates = False - for row in candidates: - if row.get("id") in affected: - changed_candidates = narrow(row) or changed_candidates - threads = store.read_jsonl("threads") - changed_threads = False - for row in threads: - if row.get("origin_candidate_id") in affected: - changed_threads = narrow(row) or changed_threads - # Hide reachable disclosures before changing the upstream retained record. - for name, rows, changed in [ - ("threads", threads, changed_threads), ("candidates", candidates, changed_candidates), - ("events", events, changed_events), ("signals", signals, changed_signals), - ]: - if changed: + # All authority was validated before the first in-memory edit. Hide downstream + # disclosures first, retaining the exact same selection as preflight. + changes = [(name, rows, [narrow(row) for row in selected]) + for name, rows, selected in closure] + for name, rows, changed in changes: + if any(changed): _rewrite_jsonl(store, name, rows) @@ -716,7 +748,7 @@ def _coalesce_candidate_with_event( incoming_candidate: dict, ) -> dict: now = utc_now_iso() - event_ids = list(candidate.get("event_ids") or []) + event_ids = list(_retained_refs(candidate, "event_ids")) if event.get("id") not in event_ids: event_ids.append(event.get("id")) candidate["event_ids"] = event_ids @@ -769,11 +801,12 @@ def _append_event_and_create_or_update_candidate( signals = store.read_jsonl("signals") candidates = store.read_jsonl("candidates") + replay_closure = [] replays, related = _preflight_event(store, event, events=events, signals=signals, - candidates=candidates) + candidates=candidates, replay_closure=replay_closure) if replays: result = _event_to_existing_result(replays[0], candidates) - _narrow_replay_visibility(store, event, event_ids=[row["id"] for row in replays]) + _narrow_replay_visibility(store, event, closure=replay_closure[0]) return result _apply_candidate_decay(store, candidates, config=config) @@ -809,7 +842,12 @@ def _append_event_and_create_or_update_candidate( } -def _preflight_event(store, event, *, events, signals, candidates): +def _preflight_event(store, event, *, events, signals, candidates, replay_closure=None): + signal_index = record_index(signals) + for identifier in _retained_refs(event, "source_signal_ids"): + found = lookup_record(signal_index, identifier) + if found.state != "unique": + raise ValueError("ambiguous_ingress_lineage") replays = _canonical_replays( events, event, classify=lambda row: classify_event_claim(store.instance, row, signals=signals), @@ -818,8 +856,10 @@ def _preflight_event(store, event, *, events, signals, candidates): if replays: for row in replays: _event_to_existing_result(row, candidates) - _narrow_replay_visibility(store, event, event_ids=[row["id"] for row in replays], - check_only=True) + closure = _narrow_replay_visibility(store, event, event_ids=[row["id"] for row in replays], + check_only=True) + if replay_closure is not None: + replay_closure.append(closure) return replays, None related = _find_related_candidate(store, candidates, event, events=events, signals=signals) return [], related diff --git a/tests/test_retained_mutation_preflight.py b/tests/test_retained_mutation_preflight.py new file mode 100644 index 0000000..d842366 --- /dev/null +++ b/tests/test_retained_mutation_preflight.py @@ -0,0 +1,239 @@ +"""Synthetic retained mutation authority, exercised through real ingress owners.""" +import copy +import json + +import pytest + +from agent_sensorium import tools +from agent_sensorium.dispatcher import candidate_to_thread +from agent_sensorium.gate import promote_signal_to_event +from tests.test_canonical_ingress_replay import call, create_role_advisory +from tests.test_core_material_replay import ingest, signal, store_at + + +def seed(tmp_path): + store = store_at(tmp_path) + row = signal(" literal signal ") + row.update(sensitivity="public_safe", allowed_surfaces=["local", "telegram"]) + result = ingest(store, row) + root = store.read_jsonl("candidates")[0] + derived = create_role_advisory(store, root) + for candidate in [root, derived]: + store.append_jsonl("threads", candidate_to_thread(candidate)) + with store.candidate_transaction(): + pass + return store, row, result + + +def snapshot(store): + return {str(p.relative_to(store.root)): p.read_bytes() + for p in store.root.rglob("*") if p.is_file()} + + +def corrupt(store, stream, field, value, descendant=False): + rows = store.read_jsonl(stream) + target = rows[-1] if descendant else rows[0] + if value == "ABSENT": + target.pop(field, None) + else: + target[field] = value + store.rewrite_jsonl(stream, rows) + + +def attempt(store, row, owner, mode): + incoming = copy.deepcopy(row) + if mode == "fresh": + incoming["id"], incoming["source_revision"] = "fresh-wrapper", "r2" + elif mode == "stricter": + incoming.update(sensitivity="local_only", allowed_surfaces=[]) + if owner == "signal": + return call(store, incoming) + event = promote_signal_to_event(incoming) + if mode != "fresh": + event["id"] = store.read_jsonl("events")[0]["id"] + if owner == "event": + return call(store, event, event=True) + try: + data = tools._append_event_and_create_or_update_candidate(store, event=event, config={}) + except ValueError as exc: + return {"success": False, "error": str(exc)} + return {"success": True, "data": data} + + +def denied_unchanged(store, row, owner, mode, monkeypatch): + effects = [] + monkeypatch.setattr(tools, "observe_after_success", lambda *a, **k: effects.append("observer")) + before = snapshot(store) + response = attempt(store, row, owner, mode) + after = snapshot(store) + assert not response["success"], response + assert after == before + assert effects == [] + + +def test_causal_dictionary_membership_fresh_revision(tmp_path, monkeypatch): + store, row, result = seed(tmp_path) + corrupt(store, "candidates", "event_ids", {result["event_id"]: True}) + denied_unchanged(store, row, "signal", "fresh", monkeypatch) + + +def test_causal_invalid_visibility_wider_replay(tmp_path, monkeypatch): + store, row, result = seed(tmp_path) + corrupt(store, "candidates", "sensitivity", "bogus") + kw = dict(instance=store.instance, state_dir=str(store.root), surface="local") + assert not json.loads(tools.handle_sensorium_candidate_open( + candidate_id=result["candidate_id"], **kw))["success"] + status = json.loads(tools.handle_sensorium_status(**kw))["data"] + assert result["candidate_id"] not in [c["id"] for c in status["top_candidates"]] + denied_unchanged(store, row, "signal", "wider", monkeypatch) + assert not json.loads(tools.handle_sensorium_candidate_open( + candidate_id=result["candidate_id"], **kw))["success"] + + +def test_causal_string_signal_membership_stricter_replay(tmp_path, monkeypatch): + store, row, _ = seed(tmp_path) + corrupt(store, "events", "source_signal_ids", row["id"]) + denied_unchanged(store, row, "signal", "stricter", monkeypatch) + + +SHAPES = ["mapping", "scalar", "string", "none", "nested", + "nonstring", "duplicate", "empty_id"] + + +def bad_refs(shape, identifier): + return {"mapping": {identifier: True}, "scalar": 7, "string": identifier, + "tuple": (identifier,), "none": None, "nested": [identifier, []], + "nonstring": [identifier, 7], "duplicate": [identifier, identifier], + "empty_id": [identifier, ""]}[shape] + + +@pytest.mark.parametrize("owner", ["signal", "event", "internal"]) +@pytest.mark.parametrize("mode", ["fresh", "wider", "stricter"]) +@pytest.mark.parametrize("stream,field", [("candidates", "event_ids"), + ("events", "source_signal_ids"), ("candidates", "source_candidate_ids")]) +@pytest.mark.parametrize("shape", SHAPES) +def test_reference_owner_matrix(tmp_path, monkeypatch, owner, mode, stream, field, shape): + store, row, result = seed(tmp_path) + identifier = {"event_ids": result["event_id"], "source_signal_ids": row["id"], + "source_candidate_ids": result["candidate_id"]}[field] + if owner != "signal" and mode == "fresh": + store.append_jsonl("signals", {**row, "id": "fresh-wrapper", "source_revision": "r2"}) + value = bad_refs(shape, identifier) + corrupt(store, stream, field, value, descendant=field == "source_candidate_ids") + denied_unchanged(store, row, owner, mode, monkeypatch) + + +@pytest.mark.parametrize("stream,descendant", [("signals", False), ("events", False), + ("candidates", False), ("candidates", True), ("threads", False), ("threads", True)]) +@pytest.mark.parametrize("field,value", [("sensitivity", "bogus"), ("sensitivity", "ABSENT"), + ("allowed_surfaces", "local"), ("allowed_surfaces", ["local", 7]), + ("allowed_surfaces", "ABSENT")]) +@pytest.mark.parametrize("mode", ["fresh", "wider", "stricter"]) +def test_visibility_closure_before_any_effect(tmp_path, monkeypatch, stream, descendant, field, value, mode): + store, row, _ = seed(tmp_path) + corrupt(store, stream, field, value, descendant) + if mode == "fresh" and (stream != "candidates" or descendant): + # Only the coalescing candidate's policy is merged on fresh ingress. + before = copy.deepcopy(store.read_jsonl(stream)) + result = attempt(store, row, "signal", mode) + assert result["success"] and result["data"]["coalesced"] + target = before[-1] if descendant else before[0] + retained = next(r for r in store.read_jsonl(stream) if r["id"] == target["id"]) + assert (field in retained) == (field in target) + assert retained.get(field) == target.get(field) + if stream in {"signals", "events", "threads"}: + assert retained == target + return + denied_unchanged(store, row, "signal", mode, monkeypatch) + + +@pytest.mark.parametrize("owner", ["signal", "event", "internal"]) +def test_valid_replay_closure_and_unrelated_visibility(tmp_path, owner): + store, row, result = seed(tmp_path) + store.append_jsonl("candidates", {"id": "unconnected", "event_ids": [], + "source_candidate_ids": [], "sensitivity": "bogus"}) + before = snapshot(store) + assert attempt(store, row, owner, "wider")["success"] + assert snapshot(store) == before + assert attempt(store, row, owner, "stricter")["success"] + after = snapshot(store) + assert after != before + for stream in ["signals", "events", "candidates", "threads"]: + for retained in store.read_jsonl(stream): + if retained["id"] != "unconnected": + if stream == "signals" and owner != "signal": + assert retained["allowed_surfaces"] == ["local", "telegram"] + continue + assert retained["allowed_surfaces"] == [] + assert retained["sensitivity"] == "local_only" + kw = dict(instance=store.instance, state_dir=str(store.root), surface="local") + assert not json.loads(tools.handle_sensorium_candidate_open( + candidate_id=result["candidate_id"], **kw))["success"] + assert attempt(store, row, owner, "wider")["success"] + assert snapshot(store) == after + + +@pytest.mark.parametrize("field", ["event_ids", "source_candidate_ids"]) +def test_tuple_reference_not_coerced_at_internal_resolution(field): + candidates = [{"id": "root", "event_ids": ["event"]}, + {"id": "derived", "event_ids": ["event"], "source_candidate_ids": ["root"]}] + candidates[1][field] = ("event" if field == "event_ids" else "root",) + with pytest.raises(ValueError, match="ambiguous_ingress_lineage"): + tools._candidate_for_event_id(candidates, "event") + + +@pytest.mark.parametrize("absent", [False, True]) +def test_standalone_legacy_empty_refs_and_missing_historical_sources(tmp_path, absent): + store = store_at(tmp_path) + event = {**promote_signal_to_event(signal()), "source_signal_ids": [], "id": " legacy event "} + if absent: + event.pop("source_signal_ids") + assert call(store, event, event=True)["success"] + before = snapshot(store) + assert call(store, event, event=True)["data"]["duplicate"] + assert snapshot(store) == before + + +@pytest.mark.parametrize("owner", ["signal", "event", "internal"]) +@pytest.mark.parametrize("stream,descendant", [("signals", False), ("events", False), + ("candidates", True), ("threads", False), ("threads", True)]) +@pytest.mark.parametrize("invalid", ["bogus", "ABSENT"]) +def test_fresh_identity_only_policy_inverse(tmp_path, owner, stream, descendant, invalid): + store, row, result = seed(tmp_path) + candidates = store.read_jsonl("candidates") + candidates[-1]["status"] = "held" + store.rewrite_jsonl("candidates", candidates) + corrupt(store, stream, "sensitivity", invalid, descendant) + if owner != "signal": + store.append_jsonl("signals", {**row, "id": "fresh-wrapper", "source_revision": "r2"}) + before = copy.deepcopy(store.read_jsonl(stream)) + target = before[-1] if descendant else before[0] + response = attempt(store, row, owner, "fresh") + assert response["success"] and response["data"]["coalesced"] + assert response["data"]["candidate_id"] == result["candidate_id"] + retained = next(r for r in store.read_jsonl(stream) if r["id"] == target["id"]) + assert json.dumps(retained, sort_keys=True) == json.dumps(target, sort_keys=True) + + +@pytest.mark.parametrize("owner", ["signal", "event", "internal"]) +@pytest.mark.parametrize("field,value", [("sensitivity", "bogus"), ("sensitivity", "ABSENT"), + ("allowed_surfaces", "local"), ("allowed_surfaces", "ABSENT")]) +def test_fresh_coalescing_owner_policy_denied(tmp_path, monkeypatch, owner, field, value): + store, row, _ = seed(tmp_path) + corrupt(store, "candidates", field, value) + if owner != "signal": + store.append_jsonl("signals", {**row, "id": "fresh-wrapper", "source_revision": "r2"}) + denied_unchanged(store, row, owner, "fresh", monkeypatch) + + +@pytest.mark.parametrize("owner", ["event", "internal"]) +@pytest.mark.parametrize("mode", ["wider", "stricter"]) +@pytest.mark.parametrize("field,value", [("sensitivity", "bogus"), ("sensitivity", "ABSENT"), + ("allowed_surfaces", "local"), ("allowed_surfaces", "ABSENT")]) +def test_event_replay_identity_only_signal_policy_inverse(tmp_path, owner, mode, field, value): + store, row, _ = seed(tmp_path) + corrupt(store, "signals", field, value) + before = store.paths["signals"].read_bytes() + response = attempt(store, row, owner, mode) + assert response["success"] and response["data"]["duplicate"] + assert store.paths["signals"].read_bytes() == before From ae90f8180d255319646539bfb0fe63ffe55d5274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 20:04:13 +0200 Subject: [PATCH 15/16] Fix current delivery owner validation and bounded disclosure --- agent_sensorium/config.py | 53 ++++- agent_sensorium/outbox.py | 65 ++++++- agent_sensorium/store.py | 6 +- tests/test_attention_lifecycle_recovery.py | 5 + ..._attention_lifecycle_review_regressions.py | 10 + tests/test_closed_visibility.py | 28 ++- tests/test_conscious_doorway.py | 62 +++++- tests/test_conscious_reachout.py | 9 + tests/test_disclosure_delivery_closure.py | 181 ++++++++++++++++++ tests/test_local_hermes_surfaces.py | 11 ++ tests/test_outbox.py | 2 + tests/test_source_closure_contract.py | 15 +- 12 files changed, 420 insertions(+), 27 deletions(-) create mode 100644 tests/test_disclosure_delivery_closure.py diff --git a/agent_sensorium/config.py b/agent_sensorium/config.py index 262db3c..7e0bd48 100644 --- a/agent_sensorium/config.py +++ b/agent_sensorium/config.py @@ -831,7 +831,13 @@ def __init__(self, candidates: list[dict], threads: list[dict], surface: str, co self.surface = surface self.config = config - def subject(self, kind: str, identifier: object, trail: frozenset = frozenset()) -> bool: + def subject(self, kind: str, identifier: object, trail: frozenset = frozenset(), + *, _depth=0, _budget=None) -> bool: + if _budget is None: + _budget = [4096] + if _depth >= 64 or _budget[0] <= 0: + return False + _budget[0] -= 1 found = lookup_record(self.indexes[kind], identifier) if found.state != "unique": return False @@ -839,16 +845,42 @@ def subject(self, kind: str, identifier: object, trail: frozenset = frozenset()) if key in trail: return False return (visible_on_surface(found.row, self.surface, self.config) - and self.linked(found.row, trail | {key}, owner=key)) + and self._linked(found.row, trail | {key}, owner=key, + _depth=_depth + 1, _budget=_budget)[0]) def linked(self, record: object, trail: frozenset = frozenset(), *, owner=None) -> bool: return self._linked(record, trail, owner=owner)[0] def _linked(self, record: object, trail: frozenset = frozenset(), *, owner=None, - source_refs=False) -> tuple[bool, bool]: + source_refs=False, _depth=0, _budget=None) -> tuple[bool, bool]: """Return validity and resolved linkage from the same traversal.""" + if _budget is None: + _budget = [4096] + if _depth >= 64 or _budget[0] <= 0: + return False, False + _budget[0] -= 1 + if isinstance(record, list): + linked = False + for child in record: + if isinstance(child, (dict, list)): + valid, resolved = self._linked(child, trail, owner=owner, + _depth=_depth + 1, _budget=_budget) + if not valid: + return False, False + linked = linked or resolved + return True, linked if not isinstance(record, dict): return False, False + if record.get("kind") == "subconscious_advisory": + metadata = record.get("advisory_meta") + nested_binding = metadata.get("admission_binding") if isinstance(metadata, dict) else None + # The canonical producer declares its source at top level and in + # advisory metadata. Derived prose cannot become standalone when + # those declarations disappear; fingerprint text is not authority. + if not (record.get("source_candidate_ids") + or isinstance(record.get("admission_binding"), dict) + or isinstance(nested_binding, dict)): + return False, False linked = False for field, kind in (("candidate_id", "candidate"), ("origin_candidate_id", "candidate"), ("thread_id", "thread")): @@ -868,7 +900,7 @@ def _linked(self, record: object, trail: frozenset = frozenset(), *, owner=None, # cycles; source bindings and origin links remain dependencies. if field in {"candidate_id", "thread_id"} and (kind, identifier) == owner: continue - if not self.subject(kind, identifier, trail): + if not self.subject(kind, identifier, trail, _depth=_depth + 1, _budget=_budget): return False, False if "source_candidate_ids" in record: identifiers = record["source_candidate_ids"] @@ -876,21 +908,24 @@ def _linked(self, record: object, trail: frozenset = frozenset(), *, owner=None, return False, False for identifier in identifiers: linked = True - if not self.subject("candidate", identifier, trail): + if not self.subject("candidate", identifier, trail, _depth=_depth + 1, _budget=_budget): return False, False binding = record.get("admission_binding") if binding is not None: linked = True - if not isinstance(binding, dict) or not self.subject("candidate", binding.get("source_candidate_id"), trail): + if not isinstance(binding, dict) or not self.subject( + "candidate", binding.get("source_candidate_id"), trail, + _depth=_depth + 1, _budget=_budget): return False, False for field, value in record.items(): if field in {"admission_binding", "source_candidate_ids"}: continue - children = [value] if isinstance(value, dict) else value if isinstance(value, list) else [] + children = [value] if isinstance(value, (dict, list)) else [] for child in children: - if isinstance(child, dict): + if isinstance(child, (dict, list)): valid, resolved = self._linked(child, trail, owner=owner, - source_refs=field == "source_refs" and isinstance(value, dict)) + source_refs=field == "source_refs" and isinstance(value, dict), + _depth=_depth + 1, _budget=_budget) if not valid: return False, False linked = linked or resolved diff --git a/agent_sensorium/outbox.py b/agent_sensorium/outbox.py index 65fc0ee..dde6098 100644 --- a/agent_sensorium/outbox.py +++ b/agent_sensorium/outbox.py @@ -18,6 +18,8 @@ import sqlite3 from copy import deepcopy +from .canonical_records import lookup_record, record_index +from .config import DisclosureVisibility, visible_on_surface from .schemas import new_id, truncate_text, utc_now_iso from .store import SensoriumStore @@ -312,6 +314,43 @@ def _denied(reason: str, detail: str, *, thread_id: str = "") -> dict: } +def _current_delivery_owner(store: SensoriumStore, request: dict) -> bool: + """Prepared policy is a ceiling, never current retained-source authority.""" + surface = request.get("surface") + policy = {"max_sensitivity": request.get("sensitivity"), + "allowed_surfaces": request.get("allowed_surfaces")} + if not visible_on_surface(request, surface, policy): + return False + candidates = store.read_jsonl("candidates") + threads = store.read_jsonl("threads") + thread_id = request.get("origin_thread_id") + candidate_id = request.get("origin_candidate_id") + if thread_id not in (None, ""): + found = lookup_record(record_index(threads), thread_id) + if found.state != "unique": + return False + origin = found.row.get("origin_candidate_id", "") + if origin not in ("", candidate_id): + return False + # Only absent/empty historical standalone origins retain compatibility. + # Present malformed/dangling declarations never acquire this exemption. + if origin == "": + threads = [dict(row) for row in threads] + for row in threads: + if row.get("id") == thread_id: + row.pop("origin_candidate_id", None) + disclosure = DisclosureVisibility(candidates, threads, surface, policy) + if not disclosure.subject("thread", thread_id): + return False + elif candidate_id in (None, ""): + return False + disclosure = DisclosureVisibility(candidates, threads, surface, policy) + if candidate_id not in (None, "") and not disclosure.subject("candidate", candidate_id): + return False + return disclosure.linked({key: value for key, value in request.items() + if key != "origin_candidate_id" or value not in (None, "")}) + + def prepare_local_outbox_request( store: SensoriumStore, *, @@ -593,6 +632,14 @@ def prepare_outbox_request( (message_preview or title or "").encode() ).hexdigest()[:16] + owner = {"origin_thread_id": thread_id, + "origin_candidate_id": origin_candidate_id or thread.get("origin_candidate_id", ""), + "surface": surface, "sensitivity": thread.get("sensitivity"), + "allowed_surfaces": thread.get("allowed_surfaces")} + if not _current_delivery_owner(store, owner): + return _denied("current_owner_not_allowed", "Current delivery owner is unresolved or withheld.", + thread_id=thread_id) + idempotency_key = _compute_idempotency_key( origin_thread_id=thread_id, delivery_mode=delivery_mode, @@ -768,7 +815,9 @@ def dispatch_outbox_request( "detail": "Dispatch requires execute=True.", } if not _locked: - with store.outbox_transaction(): + with store.outbox_transaction(), store.candidate_transaction(): + # Keep current source authority stable until the admitted effect and + # its local receipt complete; later replay governs later requests. return dispatch_outbox_request( store, outbox_id=outbox_id, @@ -779,11 +828,10 @@ def dispatch_outbox_request( ) requests = store.read_jsonl("outbox") - target_req = None - for req in requests: - if req.get("id") == outbox_id: - target_req = req - break + found = lookup_record(record_index(requests), outbox_id) + if found.state == "ambiguous": + return _denied("ambiguous_outbox", "Outbox request must resolve uniquely.") + target_req = found.row if found.state == "unique" else None if target_req is None: return {"success": False, "error": "not_found", "detail": f"Outbox request '{outbox_id}' not found."} @@ -806,6 +854,11 @@ def dispatch_outbox_request( if adapter is None: return {"success": False, "error": "no_adapter", "detail": "No Discord adapter provided for dispatch."} + if not cfg.get("enabled") or delivery_mode not in (cfg.get("allowed_delivery_modes") or []): + return _denied("delivery_mode_not_allowed", "Current outbox config does not allow delivery.") + if not _current_delivery_owner(store, target_req): + return _denied("current_owner_not_allowed", "Current delivery owner is unresolved or withheld.") + try: platform_refs: dict = {} if adapter and surface == "discord" and delivery_mode in DIRECT_DELIVERY_MODES: diff --git a/agent_sensorium/store.py b/agent_sensorium/store.py index ee17d50..1199c37 100644 --- a/agent_sensorium/store.py +++ b/agent_sensorium/store.py @@ -391,9 +391,11 @@ def outbox_transaction(self) -> Iterator[None]: path = self._root / "locks" / "outbox.lock" key = str(path.resolve(strict=False)) held_candidates = getattr(_HELD_CANDIDATE_LOCKS, "locks", None) - if held_candidates: - raise RuntimeError("outbox transactions cannot nest inside candidate transactions") held = getattr(_HELD_OUTBOX_LOCKS, "locks", None) + # Reusing an already-owned outbox is not a new lock acquisition. + # Candidate -> new outbox remains forbidden, including other roots. + if held_candidates and (not held or key not in held): + raise RuntimeError("outbox transactions cannot nest inside candidate transactions") if held and key not in held: raise RuntimeError("outbox transactions cannot nest across profile roots") process_lock = _outbox_process_lock(key) diff --git a/tests/test_attention_lifecycle_recovery.py b/tests/test_attention_lifecycle_recovery.py index 9b035aa..b9c9ccc 100644 --- a/tests/test_attention_lifecycle_recovery.py +++ b/tests/test_attention_lifecycle_recovery.py @@ -10,6 +10,7 @@ from agent_sensorium.conscious_doorway import handle_conscious_doorway_pre_llm from agent_sensorium.plugin import register from agent_sensorium.store import SensoriumStore +from tests.test_conscious_doorway import _assert_sources_unchanged, _retain_sources def _candidate(candidate_id, *, pressure=0.7): @@ -200,6 +201,7 @@ def test_foreground_doorway_attempts_resumes_and_live_tool_settles(tmp_path, mon "conscious_doorway": {"enabled": True, "aperture_size": 2, "max_active_items": 2, "lease_minutes": 10, "surfaces": ["local"], "agent_label": "Review Agent"}, })) store.rewrite_jsonl("candidates", [_candidate("alpha"), _candidate("beta")]) + parents = _retain_sources(store, ["alpha", "beta"]) assert handle_conscious_doorway_pre_llm(instance="generic", platform="discord", session_id="session-a", turn_id="turn-0", state_dir=str(state_dir)) is None first = handle_conscious_doorway_pre_llm(instance="generic", platform="local", session_id="session-a", turn_id="turn-1", state_dir=str(state_dir)) resumed = handle_conscious_doorway_pre_llm(instance="generic", platform="local", session_id="session-a", turn_id="turn-2", state_dir=str(state_dir)) @@ -242,6 +244,7 @@ def test_foreground_doorway_attempts_resumes_and_live_tool_settles(tmp_path, mon assert unsupported["success"] is False assert next(item for item in store.read_jsonl("candidates") if item["id"] == "beta")["status"] == "in_conscious_aperture" assert store.read_jsonl("outbox") == store.read_jsonl("worker_requests") == [] + _assert_sources_unchanged(store, parents) def test_plugin_pointer_receives_current_hermes_turn_fields(tmp_path, monkeypatch): @@ -259,6 +262,7 @@ def test_plugin_pointer_receives_current_hermes_turn_fields(tmp_path, monkeypatc "conscious_doorway": {"enabled": False}, })) store.append_jsonl("candidates", _candidate("pointer", pressure=0.96)) + parents = _retain_sources(store, ["pointer"]) ctx = _PluginContext() register(ctx) pointer_hook = ctx.hooks[1] @@ -272,3 +276,4 @@ def test_plugin_pointer_receives_current_hermes_turn_fields(tmp_path, monkeypatc receipts = store.read_jsonl("decisions") assert [row["foreground_turn_index"] for row in receipts] == [1, 2, 3] assert receipts[1]["reason"] == "min_turn_gap" + _assert_sources_unchanged(store, parents) diff --git a/tests/test_attention_lifecycle_review_regressions.py b/tests/test_attention_lifecycle_review_regressions.py index 8e9ef68..fb89443 100644 --- a/tests/test_attention_lifecycle_review_regressions.py +++ b/tests/test_attention_lifecycle_review_regressions.py @@ -20,6 +20,7 @@ from agent_sensorium.plugin import register from agent_sensorium.settlement import _derived_stale_aperture_ids, apply_kanban_settlement from agent_sensorium.store import SensoriumStore +from tests.test_conscious_doorway import _assert_sources_unchanged, _retain_sources from agent_sensorium.tools import ( handle_sensorium_candidate_update, handle_sensorium_compact, @@ -212,6 +213,7 @@ def test_legacy_ownerless_path_is_explicit_and_batch_wrong_owner_fails_closed(tm def test_packet_attempt_is_all_or_none_and_host_drop_never_claims_consumption(tmp_path): store = SensoriumStore(instance="packet", state_dir=str(tmp_path / "packet")) store.rewrite_jsonl("candidates", [_candidate("alpha"), _candidate("beta")]) + parents = _retain_sources(store, ["alpha", "beta"]) packet = open_conscious_aperture( store, aperture_size=2, max_active_items=2, consumer_id="owner-a", dry_run=False, now="2026-06-07T12:00:00Z", @@ -234,6 +236,7 @@ def test_packet_attempt_is_all_or_none_and_host_drop_never_claims_consumption(tm "max_active_items": 1, "surfaces": ["local"]}, })) doorway.append_jsonl("candidates", _candidate("host-drop")) + doorway_parents = _retain_sources(doorway, ["host-drop"]) proposed = handle_conscious_doorway_pre_llm( instance="generic", platform="local", session_id="session-a", turn_id="turn-a", state_dir=str(doorway_dir), @@ -243,6 +246,8 @@ def test_packet_attempt_is_all_or_none_and_host_drop_never_claims_consumption(tm attempts = [d for d in decisions if d.get("type") == "conscious.aperture.presentation_attempted"] assert len(attempts) == 1 and attempts[0]["host_consumption_confirmed"] is False assert not [d for d in decisions if d.get("type") == "conscious.aperture.consumed"] + _assert_sources_unchanged(store, parents) + _assert_sources_unchanged(doorway, doorway_parents) def test_prepared_external_work_requires_validated_core_spec(tmp_path): @@ -409,6 +414,7 @@ def test_fixed_clock_reopen_yields_incumbent_and_serves_due_without_settlement(t def test_same_owner_yield_dry_run_ineligible_and_no_contender_are_nonmutating(tmp_path): store = SensoriumStore(instance="test", state_dir=str(tmp_path / "state")) store.append_jsonl("candidates", _candidate("incumbent")) + parents = _retain_sources(store, ["incumbent"]) first = open_conscious_aperture( store, aperture_size=1, max_active_sessions=1, consumer_id="same-owner", lease_minutes=5, dry_run=False, now="2026-06-07T12:00:00Z", @@ -425,6 +431,7 @@ def test_same_owner_yield_dry_run_ineligible_and_no_contender_are_nonmutating(tm rows = store.read_jsonl("candidates") rows.append(hidden) store.rewrite_jsonl("candidates", rows) + hidden_parents = _retain_sources(store, ["hidden"]) before = {name: store.read_jsonl(name) for name in ("candidates", "decisions")} preview = open_conscious_aperture( store, aperture_size=1, max_active_items=1, consumer_id="same-owner", @@ -436,6 +443,7 @@ def test_same_owner_yield_dry_run_ineligible_and_no_contender_are_nonmutating(tm assert not [row for row in store.read_jsonl("decisions") if row.get("type") == "conscious.aperture.yielded"] assert first["aperture_id"] == renewed["aperture_id"] + _assert_sources_unchanged(store, parents + hidden_parents) def test_same_owner_yield_never_preempts_third_party_and_old_token_cannot_mutate(tmp_path): @@ -1173,6 +1181,7 @@ def test_conscious_doorway_is_local_only_and_local_desktop_surface_still_works(t candidate = _candidate("local-only") candidate["allowed_surfaces"] = ["local", "discord"] store.append_jsonl("candidates", candidate) + parents = _retain_sources(store, ["local-only"]) remote = handle_conscious_doorway_pre_llm( instance="test", @@ -1192,6 +1201,7 @@ def test_conscious_doorway_is_local_only_and_local_desktop_surface_still_works(t assert local is not None assert "[Sensorium Conscious Aperture]" in local["context"] assert store.read_jsonl("candidates")[0]["status"] == "in_conscious_aperture" + _assert_sources_unchanged(store, parents) def test_liveness_prefers_explicit_lease_expiry_with_legacy_fallback(): diff --git a/tests/test_closed_visibility.py b/tests/test_closed_visibility.py index 4420493..297ef6c 100644 --- a/tests/test_closed_visibility.py +++ b/tests/test_closed_visibility.py @@ -125,17 +125,32 @@ def test_duplicate_subject_withholds_full_serialized_prose(tmp_path, monkeypatch for p in store.root.rglob("*") if p.is_file()} == before -def _consumer_rows(marker): +def _consumer_rows(marker, *, standalone=False): from tests.test_conscious_aperture import _candidate from agent_sensorium.dispatcher import candidate_to_thread candidate = _candidate("candidate-visible") candidate.update(summary=marker) + if standalone: + candidate["kind"] = "creative_pull" + else: + candidate["source_candidate_ids"] = ["consumer-source"] candidate["conscious_task"].update(title=marker, why=marker) thread = candidate_to_thread(candidate) thread["id"] = "thread-visible" return candidate, thread +def _retain_consumer_source(store, candidate): + """Retain a separate inert synthetic canonical owner for derived fixtures.""" + if candidate.get("kind") == "subconscious_advisory": + store.append_jsonl("candidates", { + "id": "consumer-source", "kind": "design_decision", "status": "archived", + "pressure": 0, "summary": "Synthetic canonical consumer source", + "sensitivity": candidate.get("sensitivity", "private"), + "allowed_surfaces": candidate.get("allowed_surfaces", ["local"]), + }) + + def _consumer_snapshot(store): return {str(p.relative_to(store.root)): p.read_bytes() for p in store.root.rglob("*") if p.is_file()} @@ -220,6 +235,7 @@ def test_all_read_consumers_retained_shape_denial(tmp_path, monkeypatch, stream, target.pop(field) else: target[field] = value + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) _prepare_consumers(store, monkeypatch, _validate_config({})) @@ -251,6 +267,7 @@ def test_full_stream_duplicate_permutations_all_consumers(tmp_path, monkeypatch, rows = [target, duplicate] if reverse: rows.reverse() + _retain_consumer_source(store, candidate) for name, row in [("candidates", candidate), ("threads", thread)]: for retained in rows if name == stream else [row]: store.append_jsonl(name, retained) @@ -275,6 +292,7 @@ def test_policy_shape_denies_all_consumers(tmp_path, monkeypatch, field, value): store = store_at(tmp_path) marker = "Fictional malformed policy marker" candidate, thread = _consumer_rows(marker) + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) policy = _validate_config({}) @@ -295,6 +313,7 @@ def test_invalid_falsy_request_never_becomes_local(tmp_path, monkeypatch, surfac store = store_at(tmp_path) marker = "Fictional invalid request marker" candidate, thread = _consumer_rows(marker) + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) _prepare_consumers(store, monkeypatch, _validate_config({})) @@ -333,6 +352,7 @@ def test_nested_current_and_historical_links_all_consumers(tmp_path, monkeypatch else: declared = {link: identifier} candidate.update(declared) + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) receipt = {"type": "subconscious.advisory", "output_action": "SAVE", "reason": marker, **declared} @@ -360,6 +380,7 @@ def test_closed_rank_and_custom_positive_inverses(tmp_path, monkeypatch, sensiti candidate, thread = _consumer_rows(marker) for row in (candidate, thread): row.update(sensitivity=sensitivity, allowed_surfaces=[surface]) + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) policy = {"allowed_surfaces": [surface] if configured else ["local"], "max_sensitivity": maximum} @@ -392,7 +413,7 @@ def test_cycles_own_identity_and_exact_missing_do_not_fallback(tmp_path, monkeyp import json from agent_sensorium.config import DisclosureVisibility store = store_at(tmp_path) - candidate, thread = _consumer_rows("Fictional own identity marker") + candidate, thread = _consumer_rows("Fictional own identity marker", standalone=True) candidate["candidate_id"] = candidate["id"] policy = _validate_config({}) assert DisclosureVisibility([candidate], [thread], "local", policy).subject("candidate", candidate["id"]) @@ -413,7 +434,7 @@ def test_saved_positive_and_actual_cycle_consumers(tmp_path, monkeypatch, state) import json store = store_at(tmp_path) marker = "Fictional saved cycle boundary marker" - candidate, thread = _consumer_rows(marker) + candidate, thread = _consumer_rows(marker, standalone=state != "valid_saved") candidate.update(status="archived", kanban_settlement={"decision": "SAVE", "intake_task_id": "intake-fiction"}) if state == "self_cycle": candidate["source_candidate_ids"] = [candidate["id"]] @@ -423,6 +444,7 @@ def test_saved_positive_and_actual_cycle_consumers(tmp_path, monkeypatch, state) store.append_jsonl("candidates", peer) elif state == "bad_nested_binding": candidate["advisory_meta"] = {"admission_binding": {"source_candidate_id": []}} + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) _prepare_consumers(store, monkeypatch, _validate_config({})) diff --git a/tests/test_conscious_doorway.py b/tests/test_conscious_doorway.py index 46e8b73..53d18d0 100644 --- a/tests/test_conscious_doorway.py +++ b/tests/test_conscious_doorway.py @@ -74,6 +74,37 @@ def _store(tmp_path: Path, enabled=True): return store +def _retain_sources(store, subject_ids): + """Complete only explicitly named synthetic subjects before any lease.""" + from tests.test_source_closure_contract import retain_candidate_parent + + rows = store.read_jsonl("candidates") + parents = [] + for identifier in subject_ids: + subjects = [row for row in rows if row["id"] == identifier] + assert len(subjects) == 1 + subject = subjects[0] + assert not subject.get("conscious_aperture") + assert subject["source_candidate_ids"] == [] + parent_id = f"source-{identifier}" + assert not any(row["id"] == parent_id for row in rows) + subject["source_candidate_ids"] = [parent_id] + parents.append(parent_id) + store.rewrite_jsonl("candidates", rows) + for parent_id in parents: + retain_candidate_parent(store, parent_id) + return [row for row in store.read_jsonl("candidates") if row["id"] in parents] + + +def _assert_sources_unchanged(store, parents): + rows = store.read_jsonl("candidates") + for parent in parents: + retained = [row for row in rows if row["id"] == parent["id"]] + assert retained == [parent] + assert parent["status"] == "archived" and parent["pressure"] == 0 + assert parent["kind"] == "design_decision" + + def _forbidden_ledgers(store): return { name: store.read_jsonl(name) @@ -99,6 +130,7 @@ def test_implicit_root_config_activates_while_disabled_and_absent_remain_no_crea json.dumps({"conscious_doorway": {"enabled": True}}) ) enabled.append_jsonl("candidates", _candidate("cand_implicit")) + parents = _retain_sources(enabled, ["cand_implicit"]) packet = handle_conscious_doorway_pre_llm(instance="enabled", state_dir=None) assert packet and "cand_implicit" in packet["context"] @@ -115,6 +147,7 @@ def test_implicit_root_config_activates_while_disabled_and_absent_remain_no_crea absent = SensoriumStore(instance="absent") assert handle_conscious_doorway_pre_llm(instance="absent", state_dir=None) is None assert not absent.root.exists() + _assert_sources_unchanged(enabled, parents) def test_on_without_typed_advisory_is_quiet_and_nonmutating(tmp_path): @@ -129,6 +162,7 @@ def test_on_without_typed_advisory_is_quiet_and_nonmutating(tmp_path): def test_opens_one_private_packet_and_never_mutates_forbidden_ledgers(tmp_path): store = _store(tmp_path) store.append_jsonl("candidates", _candidate("cand_one")) + parents = _retain_sources(store, ["cand_one"]) packet = handle_conscious_doorway_pre_llm(instance="test", state_dir=str(store.root)) assert packet and "[Sensorium Conscious Aperture]" in packet["context"] assert "cand_one" in packet["context"] @@ -151,12 +185,14 @@ def test_opens_one_private_packet_and_never_mutates_forbidden_ledgers(tmp_path): == 1 ) assert _forbidden_ledgers(store) == {name: [] for name in _forbidden_ledgers(store)} + _assert_sources_unchanged(store, parents) def test_same_owner_resumes_and_expired_lease_is_reclaimed(tmp_path): store = _store(tmp_path) store.append_jsonl("candidates", _candidate("cand_one")) store.append_jsonl("candidates", _candidate("cand_two")) + parents = _retain_sources(store, ["cand_one", "cand_two"]) first = handle_conscious_doorway_pre_llm( instance="test", state_dir=str(store.root), session_id="same-session" ) @@ -164,7 +200,7 @@ def test_same_owner_resumes_and_expired_lease_is_reclaimed(tmp_path): instance="test", state_dir=str(store.root), session_id="same-session" ) assert first and second - assert [r["status"] for r in store.read_jsonl("candidates")] == [ + assert [r["status"] for r in store.read_jsonl("candidates") if r["id"] in ("cand_one", "cand_two")] == [ "in_conscious_aperture", "in_conscious_aperture", ] @@ -182,6 +218,10 @@ def test_same_owner_resumes_and_expired_lease_is_reclaimed(tmp_path): ) assert stale["action"] == "opened_aperture" assert stale["reclaimed_candidate_ids"] == ["cand_one"] + assert [row["id"] for row in store.read_jsonl("candidates")] == [ + "cand_one", "cand_two", "source-cand_one", "source-cand_two", + ] + _assert_sources_unchanged(store, parents) def test_raw_saved_and_malformed_candidates_remain_ineligible(tmp_path): @@ -189,12 +229,17 @@ def test_raw_saved_and_malformed_candidates_remain_ineligible(tmp_path): store.append_jsonl("candidates", _candidate("cand_raw", kind="design_insight")) store.append_jsonl("candidates", _candidate("cand_saved", status="archived")) store.append_jsonl("candidates", _candidate("cand_bad", valid=False)) + parents = _retain_sources(store, ["cand_saved", "cand_bad"]) assert handle_conscious_doorway_pre_llm(instance="test", state_dir=str(store.root)) is None - assert [r["status"] for r in store.read_jsonl("candidates")] == [ + assert [r["status"] for r in store.read_jsonl("candidates") if r["id"] in ("cand_raw", "cand_saved", "cand_bad")] == [ "candidate", "archived", "candidate", ] + assert [row["id"] for row in store.read_jsonl("candidates")] == [ + "cand_raw", "cand_saved", "cand_bad", "source-cand_saved", "source-cand_bad", + ] + _assert_sources_unchanged(store, parents) def test_compact_tool_routes_active_item_to_canonical_settlement(tmp_path, monkeypatch): @@ -208,6 +253,7 @@ def test_compact_tool_routes_active_item_to_canonical_settlement(tmp_path, monke json.dumps({"conscious_doorway": {"enabled": True}}) ) store.append_jsonl("candidates", _candidate("cand_settle")) + parents = _retain_sources(store, ["cand_settle"]) assert handle_conscious_doorway_pre_llm( instance=instance, state_dir=str(store.root), session_id="tool-session" ) @@ -235,6 +281,7 @@ def test_compact_tool_routes_active_item_to_canonical_settlement(tmp_path, monke ) assert store.read_jsonl("decisions")[-1]["type"] == "conscious.aperture.settled" assert _forbidden_ledgers(store) == {name: [] for name in _forbidden_ledgers(store)} + _assert_sources_unchanged(store, parents) def test_observer_failure_path_cannot_change_canonical_open(tmp_path, monkeypatch): @@ -295,15 +342,20 @@ def test_doorway_recovers_due_held_but_excludes_malformed_task(tmp_path): "reason_code": "time_checkpoint", } store.append_jsonl("candidates", candidate) + parents = _retain_sources(store, ["due", "bad_due"]) packet = handle_conscious_doorway_pre_llm(instance="test", state_dir=str(store.root)) assert packet and "due" in packet["context"] and "bad_due" not in packet["context"] - assert [row["status"] for row in store.read_jsonl("candidates")] == [ + assert [row["status"] for row in store.read_jsonl("candidates") if row["id"] in ("due", "bad_due")] == [ "in_conscious_aperture", "held", ] assert any( row.get("type") == "conscious.aperture.returned" for row in store.read_jsonl("decisions") ) + assert [row["id"] for row in store.read_jsonl("candidates")] == [ + "due", "bad_due", "source-due", "source-bad_due", + ] + _assert_sources_unchanged(store, parents) def test_registered_doorway_hook_accepts_documented_kwargs_without_state_dir(tmp_path, monkeypatch): @@ -318,6 +370,7 @@ def test_registered_doorway_hook_accepts_documented_kwargs_without_state_dir(tmp json.dumps({"conscious_doorway": {"enabled": True}}) ) store.append_jsonl("candidates", _candidate("registered")) + parents = _retain_sources(store, ["registered"]) ctx = FakePluginContext() register(ctx) packet = list(ctx.hooks.values())[0]["handler"]( @@ -329,11 +382,13 @@ def test_registered_doorway_hook_accepts_documented_kwargs_without_state_dir(tmp platform="local", ) assert packet and "registered" in packet["context"] + _assert_sources_unchanged(store, parents) def test_concurrent_same_owner_doorway_reuses_one_open_receipt_and_aperture_id(tmp_path): store = _store(tmp_path) store.append_jsonl("candidates", _candidate("concurrent")) + parents = _retain_sources(store, ["concurrent"]) ctx = multiprocessing.get_context("fork") ready, results, release = ctx.Queue(), ctx.Queue(), ctx.Barrier(3) processes = [ @@ -363,6 +418,7 @@ def test_concurrent_same_owner_doorway_reuses_one_open_receipt_and_aperture_id(t len(receipts) == 1 and candidate["conscious_aperture"]["id"] == receipts[0]["aperture_id"] ) assert all(receipts[0]["aperture_id"] in packet["context"] for packet in opened) + _assert_sources_unchanged(store, parents) def test_concurrent_settlement_is_idempotent_without_receipt_or_state_overwrite(tmp_path): diff --git a/tests/test_conscious_reachout.py b/tests/test_conscious_reachout.py index ea91bc3..7a4efa7 100644 --- a/tests/test_conscious_reachout.py +++ b/tests/test_conscious_reachout.py @@ -11,6 +11,7 @@ ) from agent_sensorium.outbox import FakeDiscordAdapter from agent_sensorium.store import SensoriumStore +from tests.test_source_closure_contract import retain_candidate_parent @pytest.fixture @@ -74,6 +75,8 @@ def test_subconscious_cannot_prepare_or_deliver(store): def test_conscious_can_prepare_message_without_direct_delivery(store): store.append_jsonl("threads", _thread()) + retain_candidate_parent(store, "cand_reach") + parent = store.read_jsonl("candidates")[0] result = apply_conscious_reachout_decision( store, @@ -96,6 +99,8 @@ def test_conscious_can_prepare_message_without_direct_delivery(store): assert receipt["message_chars"] > 0 assert "bounded message" not in str(receipt) assert store.read_jsonl("outbox")[0]["request_type"] == "REACH_OUT" + assert store.read_jsonl("candidates") == [parent] + assert parent["status"] == "archived" and parent["pressure"] == 0 def test_direct_reachout_requires_policy_and_adapter(store): @@ -221,6 +226,8 @@ def test_conscious_reachout_metrics_hash_unknown_breakdown_keys(store): def test_deliver_prepared_dispatches_existing_outbox_and_marks_it_dispatched(store): store.append_jsonl("threads", _thread()) + retain_candidate_parent(store, "cand_reach") + parent = store.read_jsonl("candidates")[0] prepared = apply_conscious_reachout_decision( store, decision="reach_out", @@ -256,3 +263,5 @@ def test_deliver_prepared_dispatches_existing_outbox_and_marks_it_dispatched(sto assert receipt["type"] == CONSCIOUS_REACHOUT_DELIVERED_TYPE assert receipt["outbox_id"] == outbox_id assert "Prepared direct message" not in str(receipt) + assert store.read_jsonl("candidates") == [parent] + assert parent["status"] == "archived" and parent["pressure"] == 0 diff --git a/tests/test_disclosure_delivery_closure.py b/tests/test_disclosure_delivery_closure.py new file mode 100644 index 0000000..59d7b83 --- /dev/null +++ b/tests/test_disclosure_delivery_closure.py @@ -0,0 +1,181 @@ +"""Synthetic current-owner delivery and bounded disclosure regressions.""" +import json +from agent_sensorium import tools +from agent_sensorium.config import DisclosureVisibility +from agent_sensorium.dispatcher import candidate_to_thread +from agent_sensorium.outbox import prepare_outbox_request, dispatch_outbox_request, FakeDiscordAdapter +from agent_sensorium.volunteer_cards import build_volunteer_cards +from agent_sensorium.pointers import select_attention_pointer +from tests.test_core_material_replay import store_at, signal, ingest +from tests.test_canonical_ingress_replay import create_role_advisory + +POLICY = {'max_sensitivity': 'public_safe', 'allowed_surfaces': ['local','discord']} +DIRECT = {'enabled': True, 'direct_modes_enabled': True, 'allowed_delivery_modes': ['discord_channel_thread'], 'discord': {'enabled': True}} + +def seed(tmp_path): + store = store_at(tmp_path) + (store.root / 'instance.config.json').write_text(json.dumps(POLICY)) + row = signal('fictional-source') + row.update(sensitivity='public_safe', allowed_surfaces=['local', 'discord']) + ingest(store, row) + return store, row, store.read_jsonl('candidates')[0] + +def test_outbox_stale_policy_and_inverse(tmp_path): + store, row, root = seed(tmp_path) + thread = candidate_to_thread(root) + store.append_jsonl('threads', thread) + args = dict(thread_id=thread['id'], request_type='THINK', surface='discord', delivery_mode='discord_channel_thread', target={'channel_id':'fictional-channel'}, message_preview='Fictional retained summary', config=DIRECT) + prepared = prepare_outbox_request(store, **args) + assert prepared['success'], prepared + assert prepare_outbox_request(store, **args)['idempotent_hit'] + oid = prepared['data']['id'] + adapter = FakeDiscordAdapter() + assert not dispatch_outbox_request(store, outbox_id=oid, adapter=adapter, config=DIRECT)['success'] + assert not dispatch_outbox_request(store, outbox_id=oid, adapter=adapter, execute=True)['success'] + assert adapter.calls == [] + strict = dict(row, allowed_surfaces=[], sensitivity='local_only') + assert ingest(store, strict)['duplicate'] + candidates, threads = store.read_jsonl('candidates'), store.read_jsonl('threads') + assert all(r['allowed_surfaces'] == [] for r in candidates + threads) + assert not DisclosureVisibility(candidates, threads, 'discord', POLICY).subject('thread', thread['id']) + queued = store.read_jsonl('outbox')[0] + assert queued['allowed_surfaces'] == ['discord','local'] + actual = dispatch_outbox_request(store, outbox_id=oid, adapter=adapter, config=DIRECT, execute=True) + assert not actual['success'] + assert adapter.calls == [] + assert store.read_jsonl('outbox')[0]['status'] == 'prepared' + assert not dispatch_outbox_request(store, outbox_id=oid, adapter=adapter, config=DIRECT, execute=True)['success'] + assert adapter.calls == [] + # Current source and legacy standalone thread remain accepted inverses. + other, _, current = seed(tmp_path / 'current') + for thread in (candidate_to_thread(current), dict(candidate_to_thread(current), id='fictional-standalone-thread', origin_candidate_id='')): + other.append_jsonl('threads', thread) + p = prepare_outbox_request(other, **dict(args, thread_id=thread['id'])) + a = FakeDiscordAdapter() + assert p['success'] and dispatch_outbox_request(other, outbox_id=p['data']['id'], adapter=a, config=DIRECT, execute=True)['success'] + assert len(a.calls) == 1 + print('REGRESSION 4273370996 strict replay hid source/thread; prepared policy remained broad; fake sent once; current/standalone inverses and guards held') + +def test_deep_json_loader_then_consumer(tmp_path): + store = store_at(tmp_path) + (store.root / 'instance.config.json').write_text(json.dumps(POLICY)) + base = {'id':'fictional-deep', 'status':'candidate', 'kind':'creative_pull', 'summary':'Fictional shallow summary', 'pressure':.9, 'sensitivity':'public_safe','allowed_surfaces':['local']} + # Construct bytes without invoking recursive encoder or changing recursion limits. + encoded = json.dumps(base)[:-1] + ',"metadata":' + '{"nested":' * 965 + '{}' + '}' * 965 + '}\n' + store.paths['candidates'].write_text(encoded) + loaded = store.read_jsonl('candidates') + assert len(loaded) == 1 and loaded[0]['id'] == base['id'] + neighbor = dict(base, id='fictional-neighbor', pressure=.8) + store.append_jsonl('candidates', neighbor) + loaded = store.read_jsonl('candidates') + disclosure = DisclosureVisibility(loaded, [], 'local', POLICY) + assert not disclosure.subject('candidate', base['id']) + assert disclosure.subject('candidate', neighbor['id']) + assert not any(surfaces(store, base['id']).values()) + assert all(surfaces(store, neighbor['id']).values()) + base['metadata'] = {'nested':{}} + store.rewrite_jsonl('candidates', [base]) + assert DisclosureVisibility(store.read_jsonl('candidates'), [], 'local', POLICY).subject('candidate', base['id']) + assert json.loads(tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root), surface='local', config_path=str(store.root / 'instance.config.json')))['success'] + print('REGRESSION 4273371006 JSONL accepted depth965 bytes; visibility/status raised RecursionError; shallow inverse held') + +def surfaces(store, identifier): + status = json.loads(tools.handle_sensorium_status(instance=store.instance, state_dir=str(store.root), surface='local', config_path=str(store.root / 'instance.config.json'))) + opened = json.loads(tools.handle_sensorium_candidate_open(candidate_id=identifier, instance=store.instance, state_dir=str(store.root), surface='local', config_path=str(store.root / 'instance.config.json'))) + cards = build_volunteer_cards(store, surface='local', instance_config=POLICY, limit=20) + pointer = select_attention_pointer(store, surface='local', instance_config=POLICY) + return {'status': any(c['id']==identifier for c in status['data']['top_candidates']), 'open': opened['success'], 'cards': any(c['subject_ref']['id']==identifier for c in cards), 'pointer':pointer.get('candidate_id')==identifier} + +def test_unbound_real_advisory_and_inverses(tmp_path): + store, _, root = seed(tmp_path) + derived = create_role_advisory(store, root) + assert derived['kind'] == 'subconscious_advisory' + derived['pressure'] = .99 + rows = store.read_jsonl('candidates') + rows[-1] = derived + store.rewrite_jsonl('candidates', rows) + assert all(surfaces(store, derived['id']).values()) + # Retain source-derived prose, remove source linkage only, hide actual source. + rows[0].update(allowed_surfaces=[], sensitivity='local_only') + store.rewrite_jsonl('candidates', rows) + assert not any(surfaces(store, derived['id']).values()) + for key in ('source_candidate_ids', 'admission_binding'): + derived.pop(key, None) + derived['advisory_meta'].pop('admission_binding', None) + rows[-1] = derived + store.rewrite_jsonl('candidates', rows) + actual = surfaces(store, derived['id']) + assert not any(actual.values()), actual + # Normal standalone candidate remains legitimate, unlike source-derived advisory. + standalone = dict(derived, id='fictional-standalone-candidate', kind='creative_pull') + store.rewrite_jsonl('candidates', [standalone]) + assert all(surfaces(store, standalone['id']).values()) + print('REGRESSION 4273371011 real bound advisory visible; hidden bound source suppressed all4; removed bindings exposed all4; ordinary standalone inverse held') + + +def test_current_owner_validation_serializes_replay_through_fake_dispatch(tmp_path): + """A real replay cannot commit between owner admission and fake effect.""" + from threading import Event, Thread + from agent_sensorium.store import _candidate_process_lock + + store, row, root = seed(tmp_path) + thread = candidate_to_thread(root) + store.append_jsonl("threads", thread) + args = dict(thread_id=thread["id"], request_type="THINK", surface="discord", + delivery_mode="discord_channel_thread", target={"channel_id": "fictional-channel"}, + message_preview="Fictional concurrent delivery", config=DIRECT) + first = prepare_outbox_request(store, **args) + second = prepare_outbox_request(store, **dict(args, message_preview="Fictional later delivery")) + assert first["success"] and second["success"] + probe_ready, replay_done = Event(), Event() + observed, errors = {}, [] + lock = _candidate_process_lock(str((store.root / "locks" / "candidates.lock").resolve())) + + def replay(): + try: + acquired = lock.acquire(blocking=False) + observed["blocked"] = not acquired + if acquired: + lock.release() + probe_ready.set() + observed["replay"] = ingest(store, dict(row, allowed_surfaces=[], sensitivity="local_only")) + except BaseException as exc: + errors.append(exc) + finally: + replay_done.set() + + class CoordinatedFake(FakeDiscordAdapter): + def create_thread(self, **kwargs): + worker.start() + assert probe_ready.wait(5), "Replay lock probe did not run" + if not observed["blocked"]: + assert replay_done.wait(5), "Unserialized real replay did not finish" + observed["policy_at_effect"] = store.read_jsonl("candidates")[0]["allowed_surfaces"] + return super().create_thread(**kwargs) + + worker = Thread(target=replay, daemon=True) + adapter = CoordinatedFake() + try: + result = dispatch_outbox_request(store, outbox_id=first["data"]["id"], + adapter=adapter, config=DIRECT, execute=True) + finally: + if worker.ident is not None: + worker.join(5) + assert not worker.is_alive() + assert not errors, errors + assert result["success"], result + assert observed["replay"]["duplicate"] + assert observed["blocked"], "Real candidate replay acquired ownership during admitted dispatch" + assert observed["policy_at_effect"] == row["allowed_surfaces"] + assert len(adapter.calls) == 1 + history = next(r for r in store.read_jsonl("outbox") if r["id"] == first["data"]["id"]) + assert history["status"] == "dispatched" + before = {name: store.paths[name].read_bytes() for name in ("outbox", "threads", "decisions")} + denied = dispatch_outbox_request(store, outbox_id=second["data"]["id"], + adapter=adapter, config=DIRECT, execute=True) + assert denied["error"] == "current_owner_not_allowed" + assert not dispatch_outbox_request(store, outbox_id=first["data"]["id"], + adapter=adapter, config=DIRECT, execute=True)["success"] + assert len(adapter.calls) == 1 + assert {name: store.paths[name].read_bytes() for name in before} == before + assert next(r for r in store.read_jsonl("outbox") if r["id"] == history["id"]) == history diff --git a/tests/test_local_hermes_surfaces.py b/tests/test_local_hermes_surfaces.py index ce0667c..ae8d78d 100644 --- a/tests/test_local_hermes_surfaces.py +++ b/tests/test_local_hermes_surfaces.py @@ -10,6 +10,7 @@ from agent_sensorium.pointers import handle_pointer_pre_llm from agent_sensorium.pre_llm_salience import handle_salience_pre_llm from agent_sensorium.store import SensoriumStore +from tests.test_conscious_doorway import _assert_sources_unchanged, _retain_sources LOCAL_HERMES_PLATFORMS = ("desktop", "tui", "cli", "local") @@ -71,6 +72,7 @@ def test_local_hermes_platforms_reach_local_doorway_with_exact_lease_and_no_outb store.ensure_dirs() _write_local_config(state_dir) store.append_jsonl("candidates", _candidate(f"cand_{platform}")) + parents = _retain_sources(store, [f"cand_{platform}"]) result = handle_conscious_doorway_pre_llm( instance="test", @@ -97,6 +99,7 @@ def test_local_hermes_platforms_reach_local_doorway_with_exact_lease_and_no_outb assert attempt["aperture_ids"] == [ownership["id"]] assert attempt["host_consumption_confirmed"] is False assert store.read_jsonl("outbox") == store.read_jsonl("worker_requests") == [] + _assert_sources_unchanged(store, parents) @pytest.mark.parametrize("platform", NONLOCAL_PLATFORMS) @@ -106,6 +109,7 @@ def test_remote_and_unknown_platforms_do_not_acquire_local_doorway_access(tmp_pa store.ensure_dirs() _write_local_config(state_dir) store.append_jsonl("candidates", _candidate(f"cand_blocked_{platform}")) + parents = _retain_sources(store, [f"cand_blocked_{platform}"]) result = handle_conscious_doorway_pre_llm( instance="test", @@ -119,6 +123,7 @@ def test_remote_and_unknown_platforms_do_not_acquire_local_doorway_access(tmp_pa assert store.read_jsonl("candidates")[0]["status"] == "candidate" assert store.read_jsonl("decisions") == [] assert store.read_jsonl("outbox") == store.read_jsonl("worker_requests") == [] + _assert_sources_unchanged(store, parents) def test_desktop_alias_does_not_bypass_private_candidate_policy(tmp_path): @@ -127,6 +132,7 @@ def test_desktop_alias_does_not_bypass_private_candidate_policy(tmp_path): store.ensure_dirs() _write_local_config(state_dir, max_sensitivity="local_only") store.append_jsonl("candidates", _candidate("cand_private")) + parents = _retain_sources(store, ["cand_private"]) result = handle_conscious_doorway_pre_llm( instance="test", @@ -139,6 +145,7 @@ def test_desktop_alias_does_not_bypass_private_candidate_policy(tmp_path): assert result is None assert store.read_jsonl("candidates")[0]["status"] == "candidate" assert store.read_jsonl("decisions") == [] + _assert_sources_unchanged(store, parents) @pytest.mark.parametrize("platform", LOCAL_HERMES_PLATFORMS) @@ -147,6 +154,7 @@ def test_pointer_uses_local_policy_surface_and_preserves_platform(tmp_path, plat store.ensure_dirs() _write_local_config(tmp_path) store.append_jsonl("candidates", _candidate("cand_pointer")) + parents = _retain_sources(store, ["cand_pointer"]) result = handle_pointer_pre_llm( instance="test", @@ -161,6 +169,7 @@ def test_pointer_uses_local_policy_surface_and_preserves_platform(tmp_path, plat assert receipt["type"] == "pointer.presented" assert receipt["surface"] == "local" assert receipt["platform"] == platform + _assert_sources_unchanged(store, parents) @pytest.mark.parametrize("platform", NONLOCAL_PLATFORMS) @@ -170,6 +179,7 @@ def test_pointer_and_salience_fail_closed_for_nonlocal_platforms(tmp_path, platf _write_local_config(tmp_path) store.append_jsonl("candidates", _candidate("cand_pointer")) + parents = _retain_sources(store, ["cand_pointer"]) pointer = handle_pointer_pre_llm( instance="test", platform=platform, @@ -185,6 +195,7 @@ def test_pointer_and_salience_fail_closed_for_nonlocal_platforms(tmp_path, platf assert pointer is None assert salience is None assert store.read_jsonl("decisions") == [] + _assert_sources_unchanged(store, parents) @pytest.mark.parametrize("platform", LOCAL_HERMES_PLATFORMS) diff --git a/tests/test_outbox.py b/tests/test_outbox.py index b97659d..12b21ad 100644 --- a/tests/test_outbox.py +++ b/tests/test_outbox.py @@ -19,6 +19,8 @@ def state_dir(tmp_path): def store(state_dir): s = SensoriumStore(instance="test", state_dir=state_dir) s.ensure_dirs() + s.append_jsonl("candidates", {"id": "cand_1", "kind": "creative_pull", + "sensitivity": "private", "allowed_surfaces": ["local", "discord"]}) return s diff --git a/tests/test_source_closure_contract.py b/tests/test_source_closure_contract.py index 5cce587..7fd3200 100644 --- a/tests/test_source_closure_contract.py +++ b/tests/test_source_closure_contract.py @@ -8,7 +8,9 @@ from agent_sensorium.artifacts import store_artifact from agent_sensorium.dispatcher import dispatch_once from agent_sensorium.volunteer_cards import build_volunteer_cards -from tests.test_closed_visibility import _consumer_rows, _consumer_snapshot, _prepare_consumers +from tests.test_closed_visibility import ( + _consumer_rows, _consumer_snapshot, _prepare_consumers, _retain_consumer_source, +) from tests.test_core_material_replay import store_at @@ -84,6 +86,7 @@ def test_config_presence_controls_real_disclosure(tmp_path, monkeypatch, mode): store = store_at(tmp_path) marker = "Synthetic retained subject" candidate, thread = _consumer_rows(marker) + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) store.append_jsonl("threads", thread) path = store.root / "instance.config.json" @@ -118,6 +121,7 @@ def test_real_candidate_artifact_preserves_placeholders(tmp_path, monkeypatch, p if parent == "hidden": candidate["allowed_surfaces"] = [] if parent != "missing": + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) if parent == "duplicate": store.append_jsonl("candidates", copy.deepcopy(candidate)) @@ -135,7 +139,7 @@ def test_real_candidate_artifact_preserves_placeholders(tmp_path, monkeypatch, p @pytest.mark.parametrize("bad", [None, False, 0, [], {}, "nonexistent", ""]) def test_arbitrary_thread_identifiers_are_not_placeholders(tmp_path, bad): - candidate, _ = _consumer_rows("Synthetic parent") + candidate, _ = _consumer_rows("Synthetic parent", standalone=True) disclosure = config.DisclosureVisibility([candidate], [], "local", config._validate_config({})) if bad is not None: assert not disclosure.linked({"candidate_id": candidate["id"], "thread_id": bad}) @@ -159,6 +163,7 @@ def test_dispatcher_produces_only_disclosable_threads(tmp_path, mode): candidate.pop("id") elif mode == "missing_sensitivity": candidate.pop("sensitivity") + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) if mode == "duplicate": store.append_jsonl("candidates", {**candidate, "status": "archived"}) @@ -183,10 +188,11 @@ def test_dispatcher_produces_only_disclosable_threads(tmp_path, mode): assert "Synthetic production source" in json.dumps(opened) retained = store.read_jsonl("threads")[0] assert retained["origin_candidate_id"] == candidate["id"] - store.rewrite_jsonl("candidates", [{**candidate, "status": "archived"}]) + source = next(row for row in before if row["id"] == "consumer-source") + store.rewrite_jsonl("candidates", [source, {**candidate, "status": "archived"}]) assert json.loads(tools.handle_sensorium_thread_open( thread_id=result["thread_id"], instance=store.instance, state_dir=str(store.root)))["success"] - store.rewrite_jsonl("candidates", [{k: v for k, v in candidate.items() if k != "sensitivity"}]) + store.rewrite_jsonl("candidates", [source, {k: v for k, v in candidate.items() if k != "sensitivity"}]) assert not json.loads(tools.handle_sensorium_thread_open( thread_id=result["thread_id"], instance=store.instance, state_dir=str(store.root)))["success"] assert store.read_jsonl("threads")[0] == retained @@ -198,6 +204,7 @@ def test_dispatcher_skips_ineligible_pressure_and_cannot_broaden_source(tmp_path candidate.update(status="candidate", pressure=0.7, allowed_surfaces=["local"]) high = {**candidate, "id": "candidate-high", "pressure": 0.99, "allowed_surfaces": []} store.append_jsonl("candidates", high) + _retain_consumer_source(store, candidate) store.append_jsonl("candidates", candidate) before = store.read_jsonl("candidates") result = dispatch_once(store, dry_run=False, config={ From bd98a400798f2b90a0e568d77b33ddb2f9348c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4nisch?= Date: Wed, 14 Oct 2026 20:46:48 +0200 Subject: [PATCH 16/16] Reject malformed delivery-owner declarations --- agent_sensorium/outbox.py | 23 ++-- tests/test_disclosure_delivery_closure.py | 134 ++++++++++++++++++++++ 2 files changed, 149 insertions(+), 8 deletions(-) diff --git a/agent_sensorium/outbox.py b/agent_sensorium/outbox.py index dde6098..3b5ef0a 100644 --- a/agent_sensorium/outbox.py +++ b/agent_sensorium/outbox.py @@ -323,13 +323,19 @@ def _current_delivery_owner(store: SensoriumStore, request: dict) -> bool: return False candidates = store.read_jsonl("candidates") threads = store.read_jsonl("threads") - thread_id = request.get("origin_thread_id") - candidate_id = request.get("origin_candidate_id") - if thread_id not in (None, ""): + thread_id = request.get("origin_thread_id", "") + candidate_id = request.get("origin_candidate_id", "") + # Missing/empty historical declarations are distinct from present nulls. + # Validate both selectors before choosing thread or candidate authority. + if not isinstance(thread_id, str) or not isinstance(candidate_id, str): + return False + if thread_id != "": found = lookup_record(record_index(threads), thread_id) if found.state != "unique": return False origin = found.row.get("origin_candidate_id", "") + if not isinstance(origin, str): + return False if origin not in ("", candidate_id): return False # Only absent/empty historical standalone origins retain compatibility. @@ -342,13 +348,13 @@ def _current_delivery_owner(store: SensoriumStore, request: dict) -> bool: disclosure = DisclosureVisibility(candidates, threads, surface, policy) if not disclosure.subject("thread", thread_id): return False - elif candidate_id in (None, ""): + elif candidate_id == "": return False disclosure = DisclosureVisibility(candidates, threads, surface, policy) - if candidate_id not in (None, "") and not disclosure.subject("candidate", candidate_id): + if candidate_id != "" and not disclosure.subject("candidate", candidate_id): return False return disclosure.linked({key: value for key, value in request.items() - if key != "origin_candidate_id" or value not in (None, "")}) + if key != "origin_candidate_id" or value != ""}) def prepare_local_outbox_request( @@ -633,7 +639,8 @@ def prepare_outbox_request( ).hexdigest()[:16] owner = {"origin_thread_id": thread_id, - "origin_candidate_id": origin_candidate_id or thread.get("origin_candidate_id", ""), + "origin_candidate_id": (origin_candidate_id if origin_candidate_id != "" + else thread.get("origin_candidate_id", "")), "surface": surface, "sensitivity": thread.get("sensitivity"), "allowed_surfaces": thread.get("allowed_surfaces")} if not _current_delivery_owner(store, owner): @@ -672,7 +679,7 @@ def prepare_outbox_request( "updated_at": now, "status": "prepared", "origin_thread_id": thread_id, - "origin_candidate_id": origin_candidate_id or thread.get("origin_candidate_id", ""), + "origin_candidate_id": owner["origin_candidate_id"], "request_type": request_type, "surface": surface, "delivery_mode": delivery_mode, diff --git a/tests/test_disclosure_delivery_closure.py b/tests/test_disclosure_delivery_closure.py index 59d7b83..a942bd8 100644 --- a/tests/test_disclosure_delivery_closure.py +++ b/tests/test_disclosure_delivery_closure.py @@ -1,5 +1,6 @@ """Synthetic current-owner delivery and bounded disclosure regressions.""" import json +import pytest from agent_sensorium import tools from agent_sensorium.config import DisclosureVisibility from agent_sensorium.dispatcher import candidate_to_thread @@ -179,3 +180,136 @@ def create_thread(self, **kwargs): assert len(adapter.calls) == 1 assert {name: store.paths[name].read_bytes() for name in before} == before assert next(r for r in store.read_jsonl("outbox") if r["id"] == history["id"]) == history + + +def owner_args(thread): + return dict(thread_id=thread['id'], request_type='THINK', surface='discord', + delivery_mode='discord_channel_thread', target={'channel_id': 'fictional-channel'}, + message_preview='Fictional owner summary', config=DIRECT) + + +def delivery_bytes(store): + """Include content sidecars and every retained ledger, not lock/index files.""" + return {str(path.relative_to(store.root)): path.read_bytes() + for path in store.root.rglob('*') if path.is_file() + and (path.suffix == '.jsonl' or 'content' in path.parts)} + + +@pytest.mark.parametrize('bad', [None, False, 0, [], {}]) +@pytest.mark.parametrize('override', ['default', 'matching', 'valid']) +def test_malformed_canonical_owner_denies_preparation(tmp_path, bad, override): + store, _, root = seed(tmp_path) + thread = candidate_to_thread(root) + thread['origin_candidate_id'] = bad + store.append_jsonl('threads', thread) + root['allowed_surfaces'] = [] + store.rewrite_jsonl('candidates', [root]) + args = owner_args(thread) + if override != 'default': + args['origin_candidate_id'] = bad if override == 'matching' else root['id'] + before = delivery_bytes(store) + result = prepare_outbox_request(store, **args) + assert result['error'] == 'current_owner_not_allowed', result + assert delivery_bytes(store) == before + assert FakeDiscordAdapter().calls == [] + + +@pytest.mark.parametrize('bad', [None, False, 0, [], {}]) +def test_malformed_override_is_not_defaulted(tmp_path, bad): + store, _, root = seed(tmp_path) + thread = candidate_to_thread(root) + store.append_jsonl('threads', thread) + before = delivery_bytes(store) + result = prepare_outbox_request(store, **dict(owner_args(thread), origin_candidate_id=bad)) + assert result['error'] == 'current_owner_not_allowed', result + assert delivery_bytes(store) == before + + +@pytest.mark.parametrize('bad', [None, False, 0, [], {}]) +@pytest.mark.parametrize('mutation', ['canonical', 'matching', 'candidate', 'selector']) +@pytest.mark.parametrize('hidden', [False, True]) +def test_retained_malformed_owner_denies_fake_effect(tmp_path, bad, mutation, hidden): + store, _, root = seed(tmp_path) + thread = candidate_to_thread(root) + store.append_jsonl('threads', thread) + prepared = prepare_outbox_request(store, **owner_args(thread)) + assert prepared['success'], prepared + request = prepared['data'] + if mutation in ('canonical', 'matching'): + threads = store.read_jsonl('threads') + threads[0]['origin_candidate_id'] = bad + store.rewrite_jsonl('threads', threads) + if mutation in ('matching', 'candidate'): + request['origin_candidate_id'] = bad + if mutation == 'selector': + request['origin_thread_id'] = bad + store.rewrite_jsonl('outbox', [request]) + if hidden: + root['allowed_surfaces'] = [] + store.rewrite_jsonl('candidates', [root]) + before = delivery_bytes(store) + adapter = FakeDiscordAdapter() + result = dispatch_outbox_request(store, outbox_id=request['id'], adapter=adapter, + config=DIRECT, execute=True) + assert result['error'] == 'current_owner_not_allowed', result + assert adapter.calls == [] + assert delivery_bytes(store) == before + assert store.read_jsonl('outbox')[0] == request + + +@pytest.mark.parametrize('origin', ['current', 'absent', 'empty']) +@pytest.mark.parametrize('absent_request', [False, True]) +def test_historical_owner_absence_and_current_inverse(tmp_path, origin, absent_request): + store, _, root = seed(tmp_path) + thread = candidate_to_thread(root) + if origin == 'absent': + thread.pop('origin_candidate_id') + elif origin == 'empty': + thread['origin_candidate_id'] = '' + store.append_jsonl('threads', thread) + prepared = prepare_outbox_request(store, **owner_args(thread)) + assert prepared['success'], prepared + request = prepared['data'] + if absent_request and origin != 'current': + request.pop('origin_candidate_id') + store.rewrite_jsonl('outbox', [request]) + if origin != 'current': + root['allowed_surfaces'] = [] + store.rewrite_jsonl('candidates', [root]) + history = delivery_bytes(store) + adapter = FakeDiscordAdapter() + result = dispatch_outbox_request(store, outbox_id=request['id'], adapter=adapter, + config=DIRECT, execute=True) + assert result['success'], result + assert len(adapter.calls) == 1 + after = delivery_bytes(store) + assert all(after[key].startswith(value) for key, value in history.items() + if key.endswith('decisions.jsonl')) + frozen = delivery_bytes(store) + assert not dispatch_outbox_request(store, outbox_id=request['id'], adapter=adapter, + config=DIRECT, execute=True)['success'] + assert delivery_bytes(store) == frozen + assert len(adapter.calls) == 1 + + +@pytest.mark.parametrize('absent_selector', [False, True]) +def test_valid_candidate_only_retained_request_inverse(tmp_path, absent_selector): + from agent_sensorium.outbox import prepare_local_outbox_request, _current_delivery_owner + + store, _, root = seed(tmp_path) + prepared = prepare_local_outbox_request( + store, origin_candidate_id=root['id'], request_type='THINK', surface='local', + delivery_mode='context_pointer', target={}, sensitivity='public_safe', + message_preview='Fictional candidate-only summary') + assert prepared['success'], prepared + request = prepared['data'] + if absent_selector: + request.pop('origin_thread_id') + before = delivery_bytes(store) + assert _current_delivery_owner(store, request) + assert delivery_bytes(store) == before + root['allowed_surfaces'] = [] + store.rewrite_jsonl('candidates', [root]) + before = delivery_bytes(store) + assert not _current_delivery_owner(store, request) + assert delivery_bytes(store) == before