A personality belongs to a person

Owner's correction to 1.4.0: a model's character is per (model, person), and only
the description and the notes stay instance-wide. Two people talking to one model
are not talking to the same personality, and neither can see the other's.

The administrator's box becomes the DEFAULT, resolved by `personas.effective` as
a fallback and never as a layer -- two personalities at once contradict each
other with nothing to say which is losing, which is the reasoning behind "system
prompts replace, never stack". `persona_write` takes no argument naming a model
or a person; both come from the ToolContext, so it can only write the character
it has with whoever it is talking to, and it never touches the default.

Impressions move to their own table. Not a `kind` column: 1.4.0 shipped
`UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint and this schema
is additive-only, so a discriminator would leave an upgraded instance unable to
hold both rows for one pair. That leaves the first MANUAL_STEPS entry this
project has had -- the two shapes are indistinguishable, so nothing rewrites
them: a repair would be guessing at text that is read back in the first person.

TWO BUGS FROM A PHONE

`min-width` beats both `width` and `max-width` -- CSS clamps width to max-width
and then raises the result to min-width -- so `.canvas` and `.terminal` were
384px wide on every screen narrower than that, their `min(…, 100vw)` cap
overruled, and `.inspector` had no cap at all on a width that is a preference
draggable to 2400px. None of it scrolled sideways, because all three are
`position: fixed` and fixed overflow does not extend the scrollable area -- which
is exactly why the 1.1.0 narrow pass reported these pages clean. `min-width: 0`
in the overlay query, full width below the phone breakpoint, tablet column kept.

And the install button now says why it is absent. Measured against the live
instance: the manifest meets every Chrome criterion and the blocker is a
certificate from a private CA, so the origin is not trustworthy, the service
worker is refused and no install is offered. `base.html` had been swallowing that
with an empty catch -- which kept the page working, the reason it was there, and
threw away the only evidence. It now records the outcome and `app.js` turns it
into a sentence naming the certificate, which is the cause the old hint did not
mention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 12:20:40 +00:00
co-authored by Claude Opus 5
parent df52ec9d96
commit ac51dd46cc
20 changed files with 1019 additions and 201 deletions
+208 -72
View File
@@ -1,14 +1,18 @@
"""A model's own character, and what it makes of the person in front of it.
"""A model's personality with one person, and what it makes of them.
Two things in one table, and the discriminator is a nullable column — so the
assertions that matter most are about the boundary between them: an instance-wide
persona must not be reachable as somebody's reflection, and one account's
reflection must never be visible or deletable by another. A model-written note
about a person that the person cannot read is the thing this must not become.
Both are per (model, person), in two tables — so the assertions that matter most
are about the boundaries between them: the administrator's default must not leak
*into* somebody who has their own, one account's personality and impression must
be invisible and undeletable to another, and a personality must not be reachable
through the impression route or the other way round.
A model-written text about a person that the person cannot read is the thing this
must not become, so the settings page is tested as part of the feature rather than
as decoration.
The safety story for self-modification is a record and a way back rather than a
gate, which is `SkillRevision`'s argument; the revision tests are where that is
pinned.
pinned. An impression deliberately has no history — see its own docstring.
"""
from __future__ import annotations
@@ -24,6 +28,7 @@ from lembas.db.models import (
ROLE_USER,
Chat,
Connection,
Impression,
Model,
Persona,
User,
@@ -87,47 +92,95 @@ async def _run(db, chat: Chat, name: str, args: dict):
# --- The two halves are not the same row --------------------------------------
def test_a_persona_and_a_reflection_are_separate_rows_for_one_model(db):
def test_a_personality_and_an_impression_are_separate_rows(db):
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
personas_service.write(db, model_key="test-model", owner=user, content="They test things.")
personas_service.write(db, model_key="test-model", owner=user, content="I am terse.")
personas_service.write_impression(
db, model_key="test-model", owner=user, content="They test things."
)
assert personas_service.block(db, "test-model", None) == "I am terse."
assert personas_service.block(db, "test-model", user) == "They test things."
assert personas_service.block(db, "test-model", user) == "I am terse."
assert personas_service.view_block(db, "test-model", user) == "They test things."
def test_a_missing_persona_does_not_fall_back_to_a_reflection(db):
def test_a_personality_is_each_persons_own(db):
"""The change asked for in 1.5.0: a character is something a model works out
with somebody, so two people do not share one."""
first = _user(db)
second = _second_user(db)
personas_service.write(db, model_key="test-model", owner=first, content="Blunt with them.")
personas_service.write(db, model_key="test-model", owner=second, content="Careful here.")
assert personas_service.block(db, "test-model", first) == "Blunt with them."
assert personas_service.block(db, "test-model", second) == "Careful here."
def test_a_person_without_one_of_their_own_gets_the_default(db):
"""What makes the administrator's default mean anything."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="The default.")
assert personas_service.block(db, "test-model", user) == "The default."
def test_the_default_stops_applying_once_somebody_has_their_own(db):
"""A starting point and not a layer. Two personalities at once would
contradict each other and nobody could tell which was losing -- the same
reasoning that makes system prompts replace rather than stack."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="The default.")
personas_service.write(db, model_key="test-model", owner=user, content="Mine.")
assert personas_service.block(db, "test-model", user) == "Mine."
# And the default is untouched, for everybody who has not got their own.
assert personas_service.block(db, "test-model", _second_user(db)) == "The default."
def test_a_missing_personality_does_not_fall_back_to_an_impression(db):
"""They answer different questions. A fallback between them would put "what
it makes of you" where "who it is" belongs, in the first person."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="They test things.")
assert personas_service.block(db, "test-model", None) == ""
personas_service.write_impression(
db, model_key="test-model", owner=user, content="They test things."
)
assert personas_service.block(db, "test-model", user) == ""
def test_each_model_keeps_its_own_read_of_the_same_person(db):
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="Impatient.")
personas_service.write(db, model_key="other-model", owner=user, content="Thorough.")
personas_service.write_impression(
db, model_key="test-model", owner=user, content="Impatient."
)
personas_service.write_impression(
db, model_key="other-model", owner=user, content="Thorough."
)
assert personas_service.block(db, "test-model", user) == "Impatient."
assert personas_service.block(db, "other-model", user) == "Thorough."
assert personas_service.view_block(db, "test-model", user) == "Impatient."
assert personas_service.view_block(db, "other-model", user) == "Thorough."
def test_one_accounts_reflection_is_invisible_to_another(db):
"""The whole reason the reflection is keyed on the person and not only on the
model. On an instance with two accounts, inheriting somebody else's is both
wrong and a disclosure."""
def test_one_accounts_impression_is_invisible_to_another(db):
first = _user(db)
second = _second_user(db)
personas_service.write(db, model_key="test-model", owner=first, content="Writes tests.")
personas_service.write_impression(
db, model_key="test-model", owner=first, content="Writes tests."
)
assert personas_service.block(db, "test-model", second) == ""
assert [row.content for row in personas_service.reflections_for(db, second)] == []
assert [row.content for row in personas_service.reflections_for(db, first)] == [
assert personas_service.view_block(db, "test-model", second) == ""
assert [row.content for row in personas_service.impressions_for(db, second)] == []
assert [row.content for row in personas_service.impressions_for(db, first)] == [
"Writes tests."
]
def test_one_accounts_personality_is_invisible_to_another(db):
first = _user(db)
second = _second_user(db)
personas_service.write(db, model_key="test-model", owner=first, content="Mine alone.")
assert [row.content for row in personas_service.personas_of(db, second)] == []
assert [row.content for row in personas_service.personas_of(db, first)] == ["Mine alone."]
# --- Writing, keeping, and going back -----------------------------------------
def test_every_change_keeps_what_was_there(db):
personas_service.write(db, model_key="test-model", owner=None, content="First.")
@@ -183,8 +236,9 @@ def test_an_over_long_text_is_trimmed_rather_than_refused(db):
assert len(row.content) == personas_service.MAX_PERSONA_CHARS
def test_a_reflection_is_held_to_the_shorter_limit(db):
row = personas_service.write(
def test_an_impression_is_held_to_the_shorter_limit(db):
"""Shorter on purpose: it is a standing impression, not a file."""
row = personas_service.write_impression(
db, model_key="test-model", owner=_user(db), content="y" * 5000
)
assert len(row.content) == personas_service.MAX_VIEW_CHARS
@@ -207,33 +261,49 @@ def test_the_row_survives_the_model_row_being_replaced(db):
# --- What the tools write -----------------------------------------------------
async def test_persona_write_can_only_rewrite_the_answering_model(db):
"""There is deliberately no argument naming a model: the key is the model
this reply is being written by, so a call cannot reach another one's."""
"""There is deliberately no argument naming a model or a person: both are
taken from the context, so a call cannot reach another model's character or
somebody else's copy of this one's."""
user = _user(db)
chat = _chat(db, "test-model")
outcome = await _run(db, chat, "persona_write", {"content": "I am blunt.", "why": "learnt"})
assert outcome.event["status"] == "ok"
assert personas_service.block(db, "test-model", None) == "I am blunt."
assert personas_service.block(db, "other-model", None) == ""
assert personas_service.block(db, "test-model", user) == "I am blunt."
assert personas_service.get(db, "other-model", user) is None
async def test_persona_write_never_touches_the_default(db):
"""A model editing everybody's starting point from inside one conversation is
a much larger thing than editing its own character."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="The default.")
chat = _chat(db, "test-model")
await _run(db, chat, "persona_write", {"content": "Mine now."})
assert personas_service.block(db, "test-model", user) == "Mine now."
assert personas_service.get(db, "test-model", None).content == "The default."
async def test_persona_write_is_recorded_as_the_models_own_work(db):
chat = _chat(db)
await _run(db, chat, "persona_write", {"content": "Mine."})
assert personas_service.get(db, "test-model", None).author == AUTHOR_MODEL
assert personas_service.get(db, "test-model", _user(db)).author == AUTHOR_MODEL
async def test_an_empty_persona_write_is_refused_rather_than_erasing(db):
"""It replaces rather than appends, so an empty call would be a wipe — and a
model that has been talked into one turn of nonsense should not be able to
end its own character in it."""
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="I am terse.")
chat = _chat(db)
outcome = await _run(db, chat, "persona_write", {"content": " "})
assert outcome.event["status"] == "error"
assert personas_service.block(db, "test-model", None) == "I am terse."
assert personas_service.block(db, "test-model", user) == "I am terse."
async def test_impression_write_is_keyed_on_the_person_as_well_as_the_model(db):
@@ -241,9 +311,9 @@ async def test_impression_write_is_keyed_on_the_person_as_well_as_the_model(db):
await _run(db, chat, "impression_write", {"content": "They want the short answer."})
user = _user(db)
assert personas_service.block(db, "test-model", user) == "They want the short answer."
# Not the model's own persona, which is the row next to it.
assert personas_service.block(db, "test-model", None) == ""
assert personas_service.view_block(db, "test-model", user) == "They want the short answer."
# Not the personality, which is a row in the other table.
assert personas_service.get(db, "test-model", user) is None
async def test_an_empty_impression_write_clears_it(db):
@@ -252,7 +322,7 @@ async def test_an_empty_impression_write_clears_it(db):
chat = _chat(db)
await _run(db, chat, "impression_write", {"content": "Something."})
await _run(db, chat, "impression_write", {"content": ""})
assert personas_service.block(db, "test-model", _user(db)) == ""
assert personas_service.view_block(db, "test-model", _user(db)) == ""
async def test_the_tool_is_offered_only_with_the_capability_and_the_permission(db):
@@ -286,8 +356,10 @@ def test_both_variables_are_gated_on_the_family(db):
"""A model that may not keep either has no business being handed them, and
the query should not happen at all on an instance that does not use this."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
personas_service.write(db, model_key="test-model", owner=user, content="Impatient.")
personas_service.write(db, model_key="test-model", owner=user, content="I am terse.")
personas_service.write_impression(
db, model_key="test-model", owner=user, content="Impatient."
)
chat = _chat(db)
without = _values(db, chat, families=["memory"])
@@ -300,10 +372,11 @@ def test_both_variables_are_gated_on_the_family(db):
def test_a_switched_off_persona_reads_as_absent(db):
row = personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
user = _user(db)
row = personas_service.write(db, model_key="test-model", owner=user, content="I am terse.")
row.enabled = False
db.commit()
assert personas_service.block(db, "test-model", None) == ""
assert personas_service.block(db, "test-model", user) == ""
def test_the_fragments_vanish_when_there_is_nothing_to_say(db):
@@ -324,8 +397,10 @@ def test_the_fragments_vanish_when_there_is_nothing_to_say(db):
def test_the_fragments_carry_the_texts_when_there_are_some(db):
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="I argue back.")
personas_service.write(db, model_key="test-model", owner=user, content="Likes brevity.")
personas_service.write(db, model_key="test-model", owner=user, content="I argue back.")
personas_service.write_impression(
db, model_key="test-model", owner=user, content="Likes brevity."
)
chat = _chat(db)
preamble = harness_service.compose(
@@ -346,49 +421,96 @@ def test_the_fragments_carry_the_texts_when_there_are_some(db):
# --- The screens --------------------------------------------------------------
def test_the_person_can_read_and_delete_what_a_model_makes_of_them(client, db, registered):
"""The whole reason writing one is acceptable. A model-written note about
def test_the_person_can_read_and_delete_both(client, db, registered):
"""The whole reason writing either is acceptable. Model-written text about
somebody that they cannot see is not something this should hold."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="Wants brevity.")
page = client.get("/settings")
assert "Wants brevity." in page.text
assert "What models make of you" in page.text
row = personas_service.reflections_for(db, user)[0]
client.post(f"/api/library/reflections/{row.id}/delete", follow_redirects=False)
db.expire_all()
assert personas_service.reflections_for(db, user) == []
def test_nobody_can_delete_somebody_elses_reflection(client, db):
second = _second_user(db)
row = personas_service.write(
db, model_key="test-model", owner=second, content="Theirs."
personas_service.write(db, model_key="test-model", owner=user, content="Blunt with them.")
personas_service.write_impression(
db, model_key="test-model", owner=user, content="Wants brevity."
)
response = client.post(f"/api/library/reflections/{row.id}/delete", follow_redirects=False)
page = client.get("/settings")
assert "Who each model is with you" in page.text
assert "Blunt with them." in page.text
assert "What models make of you" in page.text
assert "Wants brevity." in page.text
persona = personas_service.personas_of(db, user)[0]
impression = personas_service.impressions_for(db, user)[0]
client.post(f"/api/library/personalities/{persona.id}/delete", follow_redirects=False)
client.post(f"/api/library/impressions/{impression.id}/delete", follow_redirects=False)
db.expire_all()
assert personas_service.personas_of(db, user) == []
assert personas_service.impressions_for(db, user) == []
def test_deleting_a_personality_falls_back_to_the_default(client, db):
"""Which is what makes offering the delete reasonable: it is a reset, not the
loss of the model's character."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="The default.")
personas_service.write(db, model_key="test-model", owner=user, content="Mine.")
row = personas_service.personas_of(db, user)[0]
client.post(f"/api/library/personalities/{row.id}/delete", follow_redirects=False)
db.expire_all()
assert personas_service.block(db, "test-model", user) == "The default."
def test_nobody_can_delete_somebody_elses(client, db):
second = _second_user(db)
persona = personas_service.write(
db, model_key="test-model", owner=second, content="Theirs."
)
impression = personas_service.write_impression(
db, model_key="test-model", owner=second, content="Theirs too."
)
assert client.post(
f"/api/library/personalities/{persona.id}/delete", follow_redirects=False
).status_code == 404
assert client.post(
f"/api/library/impressions/{impression.id}/delete", follow_redirects=False
).status_code == 404
assert response.status_code == 404
db.expire_all()
assert personas_service.get(db, "test-model", second) is not None
assert personas_service.impression(db, "test-model", second) is not None
def test_a_models_own_persona_cannot_be_deleted_from_the_settings_page(client, db):
def test_the_default_cannot_be_deleted_from_the_settings_page(client, db):
"""`owner_id IS NULL` is the instance's, not this person's. An id from that
half arriving at the reader's route must be refused on ownership rather than
found by existence."""
row = personas_service.write(db, model_key="test-model", owner=None, content="Instance.")
response = client.post(f"/api/library/reflections/{row.id}/delete", follow_redirects=False)
response = client.post(
f"/api/library/personalities/{row.id}/delete", follow_redirects=False
)
assert response.status_code == 404
db.expire_all()
assert personas_service.block(db, "test-model", None) == "Instance."
assert personas_service.get(db, "test-model", None).content == "Instance."
def test_an_administrator_can_read_write_and_revert_a_persona(client, db):
def test_a_personality_cannot_be_deleted_through_the_impression_route(client, db):
"""Two tables, two routes, and an id from one must not resolve in the other --
`db.get` on the wrong class returns None, which is the answer that matters."""
user = _user(db)
row = personas_service.write(db, model_key="test-model", owner=user, content="Mine.")
response = client.post(
f"/api/library/impressions/{row.id}/delete", follow_redirects=False
)
assert response.status_code == 404
db.expire_all()
assert personas_service.block(db, "test-model", user) == "Mine."
def test_an_administrator_can_read_write_and_revert_the_default(client, db):
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
client.post(
@@ -408,7 +530,7 @@ def test_an_administrator_can_read_write_and_revert_a_persona(client, db):
page = client.get(f"/admin/models/{model.id}/edit")
assert "I am not terse at all." in page.text
assert "Earlier personalities" in page.text
assert "Earlier defaults" in page.text
client.post(
f"/admin/models/{model.id}/persona/revert",
@@ -439,7 +561,7 @@ def test_a_revision_of_another_model_cannot_be_restored_onto_this_one(client, db
assert personas_service.block(db, "test-model", None) == "Mine."
def test_clearing_the_persona_from_the_admin_page_removes_it(client, db):
def test_clearing_the_default_from_the_admin_page_removes_it(client, db):
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
@@ -448,3 +570,17 @@ def test_clearing_the_persona_from_the_admin_page_removes_it(client, db):
db.expire_all()
assert personas_service.get(db, "test-model", None) is None
assert db.scalars(select(Persona)).all() == []
def test_clearing_the_default_leaves_everybodys_own_alone(client, db):
"""They diverged from it; removing the starting point is not removing them."""
user = _user(db)
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
personas_service.write(db, model_key="test-model", owner=None, content="The default.")
personas_service.write(db, model_key="test-model", owner=user, content="Mine.")
client.post(f"/admin/models/{model.id}/persona", data={"content": ""}, follow_redirects=False)
db.expire_all()
assert personas_service.block(db, "test-model", user) == "Mine."
assert db.scalars(select(Impression)).all() == []