Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Unreleased (on main, not in v0.20.0): a daily brief that discovers an open loop writes `project_id` only when the single project is a UUID, and keeps a free-form name in `metadata_json.project_scope`. v0.20.0 wrote that name into the UUID column, so a brief with a TODO line and a project such as `Alice` failed on Postgres. No migration is required.
- Unreleased (on main, not in v0.20.0): on SQLite, `sources delete`, `sources prune --superseded` and `import-markdown --supersede` now scrub an open loop that names the source only in its metadata: the id, or `source:<id>`, as the text under `source_id`, `source_ids`, `source_ref`, `source_refs`, `source_references` or `selected_source_ids` at any depth. That is the rule the open-loop lookup of a source already used. They blanked only loops whose `source_id` column held the id, so a loop with an empty column kept its title, description and metadata, and they reached a new export. The delete preview now counts the same loops as the receipt. The rule reads the id only as stored or as `source:<id>`: a loop that names the source in any spelling other than those two keeps its text, although the memory rule reads several other spellings. No migration is required.
- Unreleased (on main, not in v0.20.0): the SQLite derived-label repair now updates and records each row under the id it is stored with (SQLite keeps capitals, missing hyphens, braces and `urn:uuid:` as written) and matches rows by the normalised id only to read the graph. If two stored spellings of one id exist, each is compared with the label its own recorded inputs give it, so neither is left at its old label while the other is relabelled, and neither is lowered. An update that changes no row stops the repair with `DerivedDomainRepairError` before any event or the completion stamp is written, on open and on restore; migration `20261004_0095` refuses the same way. Before, a derived memory stored under such an id kept its label while its relabel event and the stamp were written, and the pair survived export and import. A PostgreSQL uuid column was not affected. A vault that the earlier repair already stamped keeps such a row at its old label until a restore repairs it again. No migration is required.
- Unreleased (on main, not in v0.20.0): v0.20.0 could store a consolidation report with a sensitivity below the memories it prints. The report took its sensitivity from the near-duplicate clusters only, so a run whose proposals were roll-up cards had no cluster to take it from and was stored as `unknown`. A key whose ceiling is below those memories (a `trusted_local_agent` key for confidential ones, a `read_only_agent` key for private ones) could then read the card topic and the member ids through `GET /v0/vnext/artifacts/{id}`. The report now takes its domain and its sensitivity over every row it names: the cluster members, the members of every proposed roll-up group, the members of the groups that a skip line names by key (`topic:...`, `entity:...`, `semantic:cluster-<id>`), the pending, accepted, expired or held roll-up cards it names by id, and the sources that the `source_refs` of its cluster members name, which the report still prints. The open-loop review is labelled over the sources whose ids it prints as well as over its loops. The run digest of both reports covers those sources, so a source that was reclassified makes a new report instead of returning the earlier one. The first run after the upgrade over loops that link a source, or over cluster members that cite one, makes one new report, and a run that names no source keeps the digest it had. A report whose inputs are all unrestricted is now stored with the label of those inputs, `internal` where it was `unknown`, and every permission profile reads the two the same way. Reports stored by v0.20.0 keep their labels, because the stored-row repair does not change sensitivity. The other report producers (daily brief, weekly synthesis, connection and contradiction reports, project updates, staleness reports) were checked and already label over every row they print. Memories and sources are counted apart when the label is taken, because an id is unique only within its own table: a source that shares a memory's id can no longer replace that memory's label. No migration is required.
Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ OpenCode is an opt-in `alice-memory install` host, and from `v0.19.0` Codex is
one too. The Claude Code plugin directory ships in `v0.19.0`. Host coverage is
done, and the skill pack revisions are in design. Next is search quality: the
next release is planned as a search-quality release, ahead of new features.
Unreleased (on main, not in v0.20.0): a daily brief that discovers an open loop
writes `open_loops.project_id` only when that single project is a UUID. A
free-form name stays in `metadata_json.project_scope`. In v0.20.0 the name was
written into the UUID column and the brief failed on Postgres.

Of the former roadmap list, benchmark replication, multi-session synthesis
measurement, reference integrations, SQLite vector scale, and the enterprise
Expand Down
18 changes: 17 additions & 1 deletion apps/api/src/alicebot_api/vnext_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import re
from typing import Callable, Protocol, Sequence, cast
from uuid import UUID

from alicebot_api.vnext_derived_domain import derived_domain
from alicebot_api.vnext_agent_control import resource_project_scope
Expand Down Expand Up @@ -408,6 +409,21 @@ def _canonical_project_scope(values: Sequence[object]) -> tuple[str, ...]:
return normalize_project_scope(values)


def _stored_open_loop_project_id(project_scope: Sequence[str]) -> str | None:
"""Return a project id only when the single scope entry is a uuid.

``open_loops.project_id`` is a uuid column. A free-form name stays in
``metadata_json.project_scope``.
"""

if len(project_scope) != 1:
return None
try:
return str(UUID(str(project_scope[0])))
except (ValueError, AttributeError, TypeError):
return None


def _metadata_json(row: JsonObject) -> JsonObject:
value = row.get("metadata_json")
return value if isinstance(value, dict) else {}
Expand Down Expand Up @@ -988,7 +1004,7 @@ def _create_candidate_open_loops(
"status": "open",
"priority": "normal",
"source_id": source.get("id"),
"project_id": project_scope[0] if len(project_scope) == 1 else None,
"project_id": _stored_open_loop_project_id(project_scope),
"domain": source.get("domain", "unknown"),
"sensitivity": source.get("sensitivity", "unknown"),
"metadata_json": {
Expand Down
74 changes: 74 additions & 0 deletions tests/integration/test_daily_brief_project_id_postgres.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""A daily brief must not write a free-form project name into open_loops.project_id.

On Postgres that column is uuid. v0.20.0 wrote the single project string, so a
source scoped ``Alice`` with a TODO line aborted the brief with
``invalid input syntax for type uuid``.
"""

from __future__ import annotations

from datetime import UTC, datetime
from uuid import uuid4

from alicebot_api.db import user_connection
from alicebot_api.store import ContinuityStore
from alicebot_api.vnext_agent_control import ALL_SENSITIVITY
from alicebot_api.vnext_brain import BrainArtifactRequest, VNextBrainService
from alicebot_api.vnext_store import PostgresVNextStore


def _source(store: PostgresVNextStore, *, projects: list[str], title: str, line: str) -> None:
store.create_source(
{
"source_type": "manual_text",
"title": title,
"content_hash": f"sha256:{uuid4().hex}",
"captured_at": datetime.now(UTC),
"source_created_at": datetime.now(UTC),
"domain": "project",
"sensitivity": "internal",
"metadata_json": {
"raw_text": line,
"project_scope": projects,
},
},
actor_type="user",
)


def test_daily_brief_keeps_a_free_form_project_out_of_the_uuid_column(migrated_database_urls) -> None:
"""A name stays in metadata. A single existing project uuid is stored. Two names leave the column empty.

Mutation: in ``_stored_open_loop_project_id``, return ``project_scope[0]`` whenever the scope has one
entry. This test then fails while inserting the ``Alice`` loop.
"""

user = uuid4()
with user_connection(migrated_database_urls["app"], user) as conn:
ContinuityStore(conn).create_user(user, f"brief-{uuid4().hex}@example.invalid", "Brief")
store = PostgresVNextStore(conn)
project = store.create_project(
{
"name": "Tracked",
"slug": f"tracked-{uuid4().hex[:8]}",
"domain": "project",
"sensitivity": "internal",
},
actor_type="user",
)
project_id = str(project["id"])
_source(store, projects=["Alice"], title="Named project", line="TODO: publish the named note")
_source(store, projects=[project_id], title="Uuid project", line="TODO: publish the uuid note")
_source(store, projects=["Alice", "Bob"], title="Two projects", line="TODO: publish the pair")
VNextBrainService(store).generate_daily_brief(
BrainArtifactRequest(sensitivity_allowed=ALL_SENSITIVITY, discover_open_loops=True)
)
loops = store.list_open_loops(
status="open",
sensitivity_allowed=list(ALL_SENSITIVITY),
limit=20,
)
by_scope = {tuple(row["metadata_json"]["project_scope"]): str(row.get("project_id") or "") for row in loops}
assert by_scope[("Alice",)] == ""
assert by_scope[(project_id,)] == project_id
assert by_scope[("Alice", "Bob")] == ""
47 changes: 47 additions & 0 deletions tests/unit/test_daily_brief_project_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Candidate open loops keep a free-form project name out of project_id."""

from __future__ import annotations

from uuid import UUID

from alicebot_api.vnext_brain import BrainArtifactRequest, VNextBrainService, _stored_open_loop_project_id


class _Store:
def __init__(self) -> None:
self.payloads: list[dict] = []

def create_open_loop(self, payload: dict, *, actor_type: str = "system") -> dict:
self.payloads.append(payload)
return {"id": "loop", **payload}


def _source(projects: list[str]) -> dict:
return {
"id": "22222222-2222-2222-2222-222222222222",
"title": "Note",
"domain": "project",
"sensitivity": "internal",
"metadata_json": {"project_scope": projects},
}


def test_a_free_form_name_is_not_a_stored_project_id() -> None:
"""Mutation: return ``project_scope[0]`` for a one-item scope. ``Alice`` is then the project id."""

assert _stored_open_loop_project_id(("Alice",)) is None
assert _stored_open_loop_project_id(("prj_0123456789abcdef",)) is None
assert _stored_open_loop_project_id(("Alice", "Bob")) is None
canonical = "11111111-1111-1111-1111-111111111111"
assert _stored_open_loop_project_id((canonical.upper(),)) == canonical
assert _stored_open_loop_project_id((str(UUID(canonical)),)) == canonical

store = _Store()
VNextBrainService(store)._create_candidate_open_loops(
BrainArtifactRequest(),
[("publish the note", _source(["Alice"]))],
workflow_digest="digest",
)
payload = store.payloads[0]
assert payload["project_id"] is None
assert payload["metadata_json"]["project_scope"] == ["Alice"]
Loading