Compare commits

...
4 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 0c88e1b0b0 docs(6206): settings, projects, api routes and three models drop citations; four capitalised services stragglers
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 24s
- api.py's build-version docstring states why a channel name is not a build
  id, without the dated incident.
- rule_version states why rules need a history, without the rule that was
  rescoped and the date.
- Capitalised "Rule N" / "Milestone N" citations the earlier lowercase grep
  missed, in reply_shapes, supersession, services/family and models/family.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:42:41 -04:00
bvandeusenandClaude Opus 5.5 78e1bdbd93 docs(6206): retrieval, snippets, notes routes and rule_usage model drop citations
Corrected while reading: routes/notes.py said lessons and snippets pass the
owner to set_record_systems, "a real inconsistency". Every door passes the
caller; the comment now says so.

rule_usage's model docstring states what an outcome measures and why the
row cannot share the note table, without the measurement and record ids
that motivated it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:41:13 -04:00
bvandeusenandClaude Opus 5.5 6a9f02990c docs(6206): embedding model docstrings state the sibling-table reason in place
The rule-embedding docstring carries the own-table test itself instead of
pointing at a record; the later siblings refer back to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:39:35 -04:00
bvandeusenandClaude Opus 5.5 2a5276eb0e docs(6206): routes/lessons comments drop citations and the corpus measurement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:39:00 -04:00
15 changed files with 119 additions and 141 deletions
+19 -23
View File
@@ -10,12 +10,12 @@ from scribe.models import Base
# native pgvector `vector(384)` (see migration 0067) so similarity is ranked in
# Postgres by `<=>` rather than by a full-table Python cosine scan. The semantic
# searches rank the CALLER'S in-scope chunks exactly rather than through the
# HNSW index, which ranks before it filters (#4961; embeddings._rank_scoped).
# HNSW index, which ranks before it filters (see embeddings._rank_scoped).
EMBEDDING_DIM = 384
class NoteEmbedding(Base):
"""One embedding vector per CHUNK of a note (#280, migration 0077).
"""One embedding vector per CHUNK of a note (migration 0077).
The model reads at most 512 tokens, so a single whole-document vector
permanently lost everything past ~400 words. A note now stores one row per
@@ -42,8 +42,8 @@ class NoteEmbedding(Base):
# any note whose rows carry a stale version — shape changes become a
# version bump instead of a table wipe.
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
# embeddings.EMBEDDING_MODEL at write time — the SPACE the vector lives in
# (#4132). The column is `vector(384)`, a width and not an identity, so a
# embeddings.EMBEDDING_MODEL at write time — the SPACE the vector lives in.
# The column is `vector(384)`, a width and not an identity, so a
# swap to another 384-dim model writes a second geometry beside the first
# with no error, and cosine across the two is a number that means nothing.
# The version above says what text was embedded; this says in whose
@@ -57,14 +57,11 @@ class NoteEmbedding(Base):
class RuleEmbedding(Base):
"""One embedding vector per CHUNK of a rule (milestone 307, note 3026).
"""One embedding vector per CHUNK of a rule.
A SIBLING of NoteEmbedding rather than a generalisation of it, decided
deliberately. The reasoning below turned out to be the only written
statement of a rule Scribe applies to every record type, so it is now also
NOTE 3163 — "When a record type earns its own table" — with the worked
cases and the cost of leaving `notes`. Read that before splitting a record
type off; this stays here because it is where the decision was made.
deliberately. The reasoning below is the general test for when a record
type earns its own embedding table; the later siblings apply it too.
- The embedding ROW could have been made polymorphic. The SEARCH could not.
`semantic_search_notes` is a long function of Note-specific scoping —
@@ -96,8 +93,7 @@ class RuleEmbedding(Base):
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
# Exactly what this vector encodes — inspectable when a ranking surprises.
# For a rule this is the trigger-first document, NOT the rule's `why`:
# `why` is dated incident narrative and would drag every rule toward one
# centroid (measured in note 2485).
# `why` is incident narrative and drags every rule toward one centroid.
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
embedding_model: Mapped[str] = mapped_column(Text, nullable=False) # see NoteEmbedding
@@ -108,15 +104,15 @@ class RuleEmbedding(Base):
class MilestoneEmbedding(Base):
"""One embedding vector per CHUNK of a milestone (milestone 415).
"""One embedding vector per CHUNK of a milestone.
The third sibling, for note 3163's reason: the row could be shared, the
The third sibling, for RuleEmbedding's reason: the row could be shared, the
search cannot. A milestone is scoped by its project, has no share of its
own, and is searched to answer one question — "is there already a plan for
this?" — which no note or rule search can answer, because a plan is not a
note. Before this, a roadmap written as milestones was invisible to recall,
and every later plan was opened as a new milestone beside the one that
already described it.
note. Without it, a roadmap written as milestones is invisible to recall,
and a later plan gets opened as a new milestone beside the one that
already describes it.
The document is the title, the one-line description and the plan body, the
parts a reader uses to recognise a plan. Derived data: the startup backfill
@@ -142,15 +138,15 @@ class MilestoneEmbedding(Base):
class SystemEmbedding(Base):
"""One embedding vector per CHUNK of a System's charter (#4251).
"""One embedding vector per CHUNK of a System's charter.
The fourth sibling, for the reason note 3163 gives about the third: the row
could be shared, the search cannot. A System's `description` is a charter —
The fourth sibling, for the same reason as the others: the row could be
shared, the search cannot. A System's `description` is a charter —
several hundred words saying what belongs in that area and what does not —
and it is the answer to "which part of this codebase does X live in".
Before this there was no semantic path to one: `list_systems` enumerates,
and `search(system_id=…)` uses a System as a FILTER over notes. So a System
could narrow a search and could never be the answer to one.
Without it there is no semantic path to one: `list_systems` enumerates,
and `search(system_id=…)` uses a System as a FILTER over notes, so a System
could narrow a search and never be the answer to one.
"Where does this belong?" is a different question from "what prior art is
there?", which is why this is its own search rather than a note_type: a
+6 -7
View File
@@ -1,4 +1,4 @@
"""Family canon (milestone 463): ideas that every project on a platform shares,
"""Family canon: ideas that every project on a platform shares,
and each project's recorded answer to them.
A FAMILY IDEA is not a new kind of record. It is an existing `notes` row — a
@@ -45,7 +45,7 @@ from scribe.models import Base
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
# Each tuple is the whitelist its CHECK constraint enforces (migration 0120).
# Rule 36: a new value means DROP + ADD CONSTRAINT in the same migration.
# A new value means DROP + ADD CONSTRAINT in the same migration.
MEMBERSHIP_STATES = ("declared", "detected", "rejected")
IDEA_STATUSES = ("candidate", "canon", "retired")
ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
@@ -73,7 +73,7 @@ class Platform(Base, TimestampMixin, SoftDeleteMixin):
form a backup carries, since ids are per-install.
`markers` are glob patterns over a bound repo's paths that suggest a
project is this platform (step 2's detection). A pattern without a slash
project is this platform (platform detection). A pattern without a slash
matches a file's basename anywhere in the tree; one with a slash matches
the repo-relative path. Empty means declare-only: some platforms leave no
reliable file behind.
@@ -166,7 +166,7 @@ class FamilyIdea(Base, TimestampMixin):
comparing the two numbers, never stored as a flag that could go stale.
`topic_id` is the rule topic holding the idea's binding norms, if it has
any (the note↔topic link #3236 asked for), set by `set_topic`. The topic
any, set by `set_topic`. The topic
never carries platforms of its own; the idea's readouts list its rules,
so an assessment judges a project against them.
"""
@@ -308,8 +308,7 @@ class FamilyDecision(Base, CreatedAtMixin):
`before` and `after` are STATE snapshots — status, version, reason — and
deliberately hold no foreign ids: an id inside JSON cannot be remapped by a
restore, and would come back pointing at whatever took that number (the
#3182 trap). The ids this row needs are columns.
restore, and would come back pointing at whatever took that number. The ids this row needs are columns.
`precedent_ids` are the earlier decisions this one followed. They are what
keeps a call consistent with the last similar one when no person approves
@@ -367,7 +366,7 @@ class FamilyDecision(Base, CreatedAtMixin):
stored again inside `after` (and the prior one inside `before`), and
a promotion's evidence carries the whole criteria reasoning. So a
state here is its status, version and platforms only, and evidence is
the cited list alone (#5131). The full row is `to_dict`, which the
the cited list alone. The full row is `to_dict`, which the
decision log serves.
"""
return {
+5 -5
View File
@@ -10,12 +10,12 @@ from scribe.models.base import CreatedAtMixin, iso
SUGGESTED = "suggested"
CONFIRMED = "confirmed"
REJECTED = "rejected"
# CHECK ck_lesson_rule_links_state (migration 0111, rule 36).
# CHECK ck_lesson_rule_links_state (migration 0111).
LINK_STATES = (SUGGESTED, CONFIRMED, REJECTED)
class LessonRuleLink(Base, CreatedAtMixin):
"""A lesson says it is an instance of a rule (milestone 440, #4196).
"""A lesson says it is an instance of a rule.
A lesson records one concrete situation; a rule records the binding choice
for a class of them. The link lets the rule be reached through the
@@ -53,7 +53,7 @@ class LessonRuleLink(Base, CreatedAtMixin):
# Why it was confirmed or rejected — the reasoning a later reader needs to
# decide whether it still holds, as a rule relation's `note` is.
note: Mapped[str | None] = mapped_column(Text, nullable=True)
# What the suggestion rests on (filled by the co-surfacing recorder, #4637).
# What the suggestion rests on (filled by the co-surfacing recorder).
# Kept after a judgment so a confirmation can be read beside its evidence.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# When a judgment moved it out of `suggested`. Null while suggested.
@@ -78,12 +78,12 @@ class LessonRuleLink(Base, CreatedAtMixin):
class LessonNoRule(Base):
"""A judgment that no rule governs a lesson's situation (#4631).
"""A judgment that no rule governs a lesson's situation.
The third answer to "which rule is this an instance of?", beside naming
one and leaving it open. It is what lets an UNJUDGED lesson be told apart
from one that was looked at and found to stand alone — and a lesson with
no rule is the raw material #4634 reads for convergence, so the
no rule is the raw material convergence reads, so the
difference is the whole signal.
A row of its own rather than a key in the lesson's `data`: that column is
+19 -22
View File
@@ -7,16 +7,15 @@ from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced"
PULLED = "pulled"
# The outcome half (#4212, milestone 419). A rule that was read and then
# ignored has always been indistinguishable from one that was read and
# obeyed; these are the two events that can tell them apart.
# The outcome half. Without these, a rule that was read and then ignored is
# indistinguishable from one that was read and obeyed; these are the two
# events that can tell them apart.
#
# There is deliberately NO third value for "read and ignored". That state is
# real and is the whole point of the milestone, but it cannot be reported:
# an agent that knew it was ignoring a rule would not be ignoring it. It is
# real and is the point of measuring outcomes, but it cannot be reported: an
# agent that knew it was ignoring a rule would not be ignoring it. It is
# DERIVED — a pull with no outcome — and an enum member for it would collect
# nothing while reading as though it had measured something, which is #3311's
# failure exactly.
# nothing while reading as though it had measured something.
APPLIED = "applied"
DEPARTED = "departed"
OUTCOMES = (APPLIED, DEPARTED)
@@ -26,15 +25,14 @@ class RuleUsageEvent(Base, CreatedAtMixin):
"""One row per time a rule was SURFACED to the agent, PULLED in full, or
ACTED ON — applied, or departed from with a stated reason.
The sibling `note_usage_events` has had since 2026-07, third in the line
after `rule_embeddings` and `rule_versions` — and, like those, it exists
because the rule side kept inheriting machinery built for notes and
quietly getting the weaker version of it.
The rule-side sibling of `note_usage_events`, beside `rule_embeddings`
and `rule_versions` — each exists because machinery built for notes gives
rules the weaker version of it.
WHY RULES NEED THEIR OWN AND CANNOT SHARE THE NOTE TABLE. Not squeamishness
about a polymorphic column — the row shares no note-specific fields and the
aggregate readout is the same shape, which is the strongest case for
sharing that note #3163 admits. What decides it is IDENTITY AT RESTORE. A
sharing there is. What decides it is IDENTITY AT RESTORE. A
note id and a rule id are different namespaces resolved through different
maps, and `note_usage_events`'s importer maps `note_id` through
`note_id_map` and drops what does not resolve. A rule id parked in that
@@ -42,11 +40,10 @@ class RuleUsageEvent(Base, CreatedAtMixin):
happened to take that number — telemetry that is not merely lost but wrong,
and wrong in a way nothing downstream could detect.
WHAT THIS MEASURES, AND WHY IT DID NOT EXIST. The write-path standing-rule
arm is the only retrieval surface in Scribe whose usefulness cannot be
observed — and, not coincidentally, the only one that has never declined to
fire (#3311: 296 calls, zero zero-result, 100% clearing its threshold).
`retrieval_logs` gives it scores; scores say what the ranker thought, never
WHAT THIS MEASURES. A standing-rule arm's usefulness cannot be observed
from its scores: an arm can fire on every call, clearing its threshold
every time, and say nothing about whether it helped. `retrieval_logs`
gives it scores; scores say what the ranker thought, never
whether the hint landed. Without a pull counter no install can tune the arm
from evidence, only from the shape of a histogram.
@@ -58,7 +55,7 @@ class RuleUsageEvent(Base, CreatedAtMixin):
forty times and opened never, because that evidence is precisely the case
for having deleted it.
Cells left deliberately empty (note #3163's step 3): no share ACL — rules
Cells left deliberately empty: no share ACL — rules
have none of their own; no soft delete — nothing restores a telemetry row,
and the table is append-only; no embedding — an event is not a document.
"""
@@ -76,7 +73,7 @@ class RuleUsageEvent(Base, CreatedAtMixin):
# 'surfaced' | 'pulled' | 'applied' | 'departed'
#
# Plain Text with no CHECK, as created in 0094 — which is why 0106 added
# the outcome pair without a DROP/ADD migration. Rule 36 governs
# the outcome pair without a DROP/ADD migration — that step is for
# CHECK-whitelisted columns and this is not one. Said here as well as in
# the migration because this is where the next person adding a value will
# look first.
@@ -84,9 +81,9 @@ class RuleUsageEvent(Base, CreatedAtMixin):
# Which surface produced it. A CONVENTION, not a fixed vocabulary, and the
# note twin's comment explains why this one deliberately does not enumerate
# its members: the previous such list went stale, naming a source nothing
# wrote while omitting ones that existed, and a half-true enumeration reads
# as authoritative in exactly the way that misleads (#2476).
# its members: such a list goes stale, naming a source nothing writes while
# omitting ones that exist, and a half-true enumeration reads as
# authoritative in exactly the way that misleads.
# `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the
# authoritative list, and unlike a comment it cannot drift.
#
+5 -8
View File
@@ -10,18 +10,15 @@ from scribe.models.base import CreatedAtMixin, iso
class RuleVersion(Base, CreatedAtMixin):
"""One snapshot of a rule's text, taken before an edit overwrote it.
THE SIBLING NOTES ALREADY HAD. `note_versions` has existed for a long
time, and the design-system note calls its history "the changelog". Rules
— which BIND BEHAVIOUR on every session that loads them — had nothing, so
an edit destroyed what the rule used to say. Rescoping rule 79 on
2026-08-29 meant hand-copying the superseded statement into a task log to
keep it (#3237). The more consequential record had the weaker protection.
THE SIBLING OF `note_versions`. Rules BIND BEHAVIOUR on every session that
loads them; without a history, an edit destroys what the rule used to say,
and the more consequential record has the weaker protection.
`CreatedAtMixin`, not `TimestampMixin`: a version is an EVENT. It is
written once and never updated, so an `updated_at` on it would be a column
that can only ever lie.
WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion (milestone 323):
WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion:
- `user_id` is the ACTOR — who made the edit — where NoteVersion's is the
owner, because `update_note` passes an owner-scoped id. For an audit
@@ -34,7 +31,7 @@ class RuleVersion(Base, CreatedAtMixin):
own lifecycle; folding them in would make one word, "version", mean two
different things — the rule's wording, and the rule's place in the
graph. `verified_at` is likewise absent: it is a stamp about a check,
not a property of the text, and step 2's snapshot is taken before
not a property of the text, and the snapshot is taken before
update_rule clears it precisely so the history shows the check that was
in force when this wording was written.
"""
+6 -9
View File
@@ -12,14 +12,11 @@ async def health():
def build_version_payload() -> dict:
"""What build is this, separated into the values that answer different
questions (rule 149).
questions.
UNTIL 2026-08-31 THIS RETURNED THE CHANNEL. `BUILD_VERSION` in CI was
literally "dev" / "main" / the tag, so a running instance reported
`{"version": "main"}` — a channel name sitting where a build identifier
belongs. The cost was concrete: with a deploy misbehaving, nothing on the
instance could say which commit was serving it, and the one endpoint whose
job that is answered with the name of a branch.
A channel name ("dev", "main") is not a build identifier: with a deploy
misbehaving, the one endpoint whose job is to say which commit is serving
must not answer with the name of a branch.
The three values, and why they are three:
@@ -33,7 +30,7 @@ def build_version_payload() -> dict:
- `channel` — its own field, never folded into the name.
Plus `commit`, so the artifact's claim about itself can be checked against
the `:<sha>` it was published under (rule 145).
the `:<sha>` it was published under.
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key
and no channel, and saying so is honest; emitting `""` or a placeholder
@@ -43,7 +40,7 @@ def build_version_payload() -> dict:
payload: dict = {"version": os.environ.get("APP_VERSION", "dev")}
# Reported verbatim, never validated against an enum — a build claiming
# something unexpected is better shown than dropped (rule 149).
# something unexpected is better shown than dropped.
for key, env in (("channel", "APP_CHANNEL"), ("commit", "APP_COMMIT")):
value = (os.environ.get(env) or "").strip()
if value:
+11 -15
View File
@@ -7,18 +7,16 @@ contract and the `data` mirror live in one place — the same division snippets
use, and for the same reason: two doors that each compose a lesson would
compose it two ways, and the document IS what ranks.
ACL (rule #78): reads and writes of a single lesson resolve through the
ACL: reads and writes of a single lesson resolve through the
share-aware `get_lesson` / `can_write_note`, and writes are performed as the
OWNER so a shared editor isn't rejected by the owner-scoped service —
mirroring routes/snippets.py and routes/notes.py.
WHY THE TRIGGER IS A NAMED FIELD HERE TOO. The web editor could have posted a
body and let the service parse it. It doesn't, because the evidence behind
this kind (step 1) is that a trigger gets filled when a door ASKS for it by
name — the snippet corpus is at 100% on its trigger with no guard anywhere,
because a service composes the title from a parameter. A form that offered one
markdown box would be the option milestone 385 rejected, wearing a different
hat.
body and let the service parse it. It doesn't, because a trigger gets filled
when a door ASKS for it by name — a field the service composes from a named
parameter is filled every time, with no guard anywhere, and a single markdown
box is not.
"""
import logging
@@ -63,7 +61,7 @@ async def list_lessons_route():
project_id = None
limit, offset = parse_pagination(default_limit=24, max_limit=100)
# The lessons nobody has answered "which rule?" for (#4631). A listing,
# The lessons nobody has answered "which rule?" for. A listing,
# so it does not combine with a search — the MCP door says the same.
if request.args.get("unjudged") in ("1", "true"):
if q:
@@ -87,7 +85,6 @@ async def list_lessons_route():
# construction. Every row gets the key, zero-filled, so the UI renders
# "never surfaced" rather than treating a missing field as a state.
#
# Lessons were the one kind collecting this and showing it nowhere (#4196).
# The counts matter more here than on a snippet: the promotion question a
# lesson eventually raises — does this bind? — is answered by repeatedly
# surfaced AND repeatedly opened, and the far commoner reading of the same
@@ -122,8 +119,7 @@ async def lessons_taught_by_route(record_id: int):
@lessons_bp.route("/rule-candidates", methods=["GET"])
@login_required
async def lesson_rule_candidates_route():
"""The rules a lesson being written resembles, BEFORE it is saved
(milestone 440, #4658).
"""The rules a lesson being written resembles, BEFORE it is saved.
The create door offers the same list in its reply when neither answer was
given. A form needs it earlier, so the writer can name the rule in the
@@ -178,7 +174,7 @@ async def create_lesson_route():
project_id = data.get("project_id") or None
learned_from = data.get("learned_from") or []
# The rules this lesson is an instance of (milestone 440). Validated before
# The rules this lesson is an instance of. Validated before
# anything is written, as the MCP door does, so a bad id saves nothing.
no_rule = (data.get("no_rule") or "").strip()
try:
@@ -264,7 +260,7 @@ async def get_lesson_route(lesson_id: int):
await lesson_rules_svc.attach_lesson_rules(uid, [out])
# Opening the detail view IS a pull — the operator chose to look. Tagged
# apart from the MCP sources so "an agent was handed it" and "a human read
# it" stay distinguishable; they mean different things for pruning (#2085).
# it" stay distinguishable; they mean different things for pruning.
record_pulled(user_id=uid, note_id=lesson_id, source="rest_lesson")
return jsonify(out)
@@ -322,7 +318,7 @@ async def update_lesson_route(lesson_id: int):
if no_rule:
await lesson_rules_svc.set_no_rule(uid, lesson_id, no_rule)
if data.get("system_ids") is not None:
# The CALLER, not owner_uid (#4249). `set_record_systems` runs its own
# The CALLER, not owner_uid. `set_record_systems` runs its own
# `can_write_note` and links only Systems the acting user can read;
# handing it the owner makes that check trivially pass and filters by
# the owner's visibility instead. The caller's write permission is
@@ -331,7 +327,7 @@ async def update_lesson_route(lesson_id: int):
await systems_svc.set_record_systems(uid, lesson_id, data["system_ids"])
out = lessons_svc.lesson_to_dict(updated)
if no_rule:
# The same nudge the MCP door gives (#4634): this answer may complete
# The same nudge the MCP door gives: this answer may complete
# a group of no-rule lessons in one situation.
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
+11 -15
View File
@@ -131,10 +131,8 @@ async def create_note_route():
# 403, not 400: the request is well-formed and the caller simply
# may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
# #4249: this door could not file a note to a System at all, while the
# MCP door could — the reverse of the asymmetry the same issue records for
# lessons. The pattern is not that one door is richer; it is that the door
# nobody exercised for a kind is the one that never grew the parameter.
# Both doors file a note to Systems: the door nobody exercises for a kind
# is the one that never grows the parameter.
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = note.to_dict()
@@ -235,7 +233,7 @@ async def get_note_route(note_id: int):
# join away, but which surface asked is not recoverable after the fact.
# Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that
# injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
# clicking a link would inflate exactly the number pruning gates on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await supersession_svc.attach_relations(uid, note_id, data)
return jsonify(data)
@@ -246,8 +244,8 @@ async def get_note_route(note_id: int):
async def update_note_route(note_id: int):
"""Partial update — only the keys present in the payload change. PUT and
PATCH are the same handler on purpose: the form sends the field set it
edited, and the two verbs used to be two near-identical copies of this
function that drifted (one carried the supersedes contract, one did not)."""
edited, and two copies of this function would drift (one carrying the
supersedes contract, one not)."""
uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service.
@@ -265,7 +263,7 @@ async def update_note_route(note_id: int):
"milestone_id", "status", "priority", "note_type",
# A cleared form input arrives as "" and the service reads that as
# NULL (NULLABLE_NOTE_TEXT), so this door expresses "remove the check"
# with its own idiom and needs no `clear` list (milestone 317).
# with its own idiom and needs no `clear` list.
"verify_with", "expires_when",
):
if key in data:
@@ -298,12 +296,10 @@ async def update_note_route(note_id: int):
return jsonify({"error": str(exc)}), 403
# Set-semantics like the above: present-and-empty clears, absent leaves
# alone. Scoped by the CALLER rather than owner_uid, for the same reason
# the supersedes call is (#47) — `set_record_systems` links only Systems
# the acting user can read, and an editor-share holder should tag from
# what they can see rather than inherit the owner's reach. This matches
# routes/tasks.py; routes/lessons.py and routes/snippets.py pass the owner
# for the same operation, which is a real inconsistency, recorded in #4249
# rather than changed here.
# the supersedes call is — `set_record_systems` links only Systems the
# acting user can read, and an editor-share holder should tag from what
# they can see rather than inherit the owner's reach. Every door that
# tags a record (tasks, lessons, snippets) does the same.
if "system_ids" in data:
await systems_svc.set_record_systems(uid, note_id, data["system_ids"] or [])
out = note.to_dict()
@@ -521,7 +517,7 @@ async def graph_route():
return jsonify(graph)
# ── The staleness sweep (milestone 317) ──────────────────────────────────────
# ── The staleness sweep ──────────────────────────────────────────────────────
# The web half of notes_due_for_verification / mark_note_verified. Same
# contract as the MCP door and the rules routes beside it — the service holds
# the behaviour, these two just parse and serialise.
+8 -8
View File
@@ -33,10 +33,10 @@ async def list_projects_route():
projects = await list_projects_for_user(uid, status=status)
if include_summary:
# Batched: four queries plus two, in two sessions, for ALL projects.
# This replaced an asyncio.gather over a per-project summary that opened
# its own session and then one more per milestone — ~250 concurrent
# checkouts against a pool of 15, all waiting out the 30s timeout and
# starving every other route on the instance (#2384).
# An asyncio.gather over a per-project summary would open a session per
# project and one more per milestone — far more concurrent checkouts
# than the pool holds, all waiting out the checkout timeout and
# starving every other route on the instance.
#
# Grouped by OWNER because a shared project's counts belong to its
# owner's records, matching what the per-project path passed.
@@ -67,7 +67,7 @@ async def create_project_route():
status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
# The inception decision rides the create (milestone 297): the UI's
# The inception decision rides the create: the UI's
# second step sends `inception: {choices}`; absent = undecided, and the
# project page shows the card until it is. Validated before the create
# so a bad decision never leaves a half-made project behind.
@@ -98,7 +98,7 @@ async def create_project_route():
@projects_bp.route("/<int:project_id>/inception", methods=["POST"])
@login_required
async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297.
"""Record (or re-record) what a project inherits.
Body: the choices object {design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id()
data = await request.get_json() or {}
@@ -185,7 +185,7 @@ async def get_coverage_route(project_id: int):
project, _ = result
owner_uid = project.user_id or uid
return jsonify({
# The OWNER's keyring (#2778) — whether refresh could do anything,
# The OWNER's keyring — whether refresh could do anything,
# regardless of who is looking.
"configured": (await get_forges(owner_uid, project_id)).configured,
"coverage": await cached_coverage(owner_uid, project_id),
@@ -230,7 +230,7 @@ async def refresh_coverage_route(project_id: int):
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
"""Pin the project to one forge connection, or clear the pin (#2778).
"""Pin the project to one forge connection, or clear the pin.
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
the pin can only reference a connection the project OWNER holds — the
+9 -9
View File
@@ -1,12 +1,12 @@
"""The operator's view of retrieval tuning — what the dials are, and who moved them (#4102).
"""The operator's view of retrieval tuning — what the dials are, and who moved them.
WHY THIS EXISTS SEPARATELY FROM /api/settings
The numbers themselves are ordinary settings rows and could be written through
the generic KV endpoint. They must not be, and that is the whole point of this
blueprint: a floor changed through `/api/settings` moves silently, leaving the
history saying nothing happened. From milestone 416 those dials are moved by
the model on the operator's behalf, so a trail with holes in it is worse than
history saying nothing happened. Those dials are moved by the model on the
operator's behalf, so a trail with holes in it is worse than
no trail — it reads as complete.
So every change to a retrieval dial goes through `set_dial`, from the UI as
@@ -39,7 +39,7 @@ retrieval_bp = Blueprint("retrieval", __name__, url_prefix="/api/retrieval")
# behalf. The operator IS that someone: they are the audience the trail is
# written for, they cannot be uninformed about their own decision, and a
# mandatory justification textarea on every control would be friction charged
# to the one participant who owes no explanation (rule 24).
# to the one participant who owes no explanation.
#
# The event is still written, because "the operator set this by hand" is the
# single most useful thing the history can tell a later session — it is the one
@@ -116,12 +116,12 @@ async def tuning_history_route():
@retrieval_bp.route("/moments", methods=["GET"])
@login_required
async def moments_route():
"""The moments of work that rules mount on (milestone 458), with the
"""The moments of work that rules mount on, with the
actions that reach each one on this install.
The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments — plus what
the view shows beside each one (step 6): `mounted`, how many rules each
the view shows beside each one: `mounted`, how many rules each
carries, and `usage`, the deliveries and opens per moment over `days`
(the block `retrieval_telemetry` reports as `moment_usage`).
"""
@@ -147,11 +147,11 @@ async def moments_route():
@retrieval_bp.route("/reply-shapes", methods=["GET"])
@login_required
async def reply_shapes_route():
"""The default reply shapes and the moment each rides (milestone 500).
"""The default reply shapes and the moment each rides.
`list_reply_shapes`' payload from the same service, so the Settings view
and the session cannot show different defaults — plus what only the
operator's view needs (step 5): per shape, the preferences mounted on its
operator's view needs: per shape, the preferences mounted on its
moment and its deliveries over `days` (default 30, 1–90).
"""
try:
@@ -201,7 +201,7 @@ async def unmap_action_route():
@retrieval_bp.route("/moments/proposals", methods=["GET"])
@login_required
async def moment_proposals_route():
"""The moment proposals waiting on a judgment (milestone 458 step 7) —
"""The moment proposals waiting on a judgment —
`rule_moment_proposals`' payload, from the same service. `?rule_id=`
narrows to one rule, for the rule editor."""
try:
+7 -7
View File
@@ -24,7 +24,7 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw.
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
# (No forge_token here: forge credentials are keyring rows, since migration 0078.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
# What the tuning history records for a dial changed through this form. The MCP
@@ -62,10 +62,10 @@ async def update_settings_route():
for k, v in data.items():
str_v = str(v)
# A retrieval dial is never written as a plain settings row, wherever
# the request came from (#4102). The value would land correctly and the
# tuning history would say nothing happened — and since milestone 416
# those dials are moved by the model on the operator's behalf, a
# history with holes in it is worse than none: it reads as complete.
# the request came from. The value would land correctly and the tuning
# history would say nothing happened — and since those dials are moved
# by the model on the operator's behalf, a history with holes in it is
# worse than none: it reads as complete.
#
# Routed rather than refused on purpose. Refusing would work only for
# callers that had been updated; this way every caller that ever writes
@@ -113,7 +113,7 @@ async def test_search():
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# --- forge connections ---------------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
@@ -201,7 +201,7 @@ async def delete_forge_connection_route(connection_id: int):
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied per keyring row)."""
fallbacks later, per keyring row."""
from scribe.services.forge import ForgeError, build_adapter
from scribe.services.forge_connections import connection_token, get_connection
+6 -6
View File
@@ -5,7 +5,7 @@ routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the
agent-facing surface. Both go through services/snippets.py, so the
serialize/parse contract and embedding-on-create live in one place (DRY).
ACL (rule #78): reads/writes of a single snippet resolve through the share-aware
ACL: reads/writes of a single snippet resolve through the share-aware
`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so
a shared editor isn't rejected by the owner-scoped service — mirroring
routes/notes.py. The list is owner-scoped, matching the note-browse surface.
@@ -65,7 +65,7 @@ async def list_snippets_route():
repo = request.args.get("repo", "")
path = request.args.get("path", "")
symbol = request.args.get("symbol", "")
# Drift check (#2086). "attention" is what the UI's filter chip sends.
# Drift check. "attention" is what the UI's filter chip sends.
verification = request.args.get("verification", "")
limit, offset = parse_pagination()
items, total = await snippets_svc.list_snippets(
@@ -115,7 +115,7 @@ async def create_snippet_route():
is_task=False,
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
# Matched on the artefact — location and code — before prose, the
# same way the MCP create path does (#2518). Both surfaces must
# same way the MCP create path does. Both surfaces must
# apply the identical gate or the web UI becomes the way to record
# a duplicate the agent would have been stopped from writing.
code=data.get("code", ""),
@@ -163,7 +163,7 @@ async def get_snippet_route(snippet_id: int):
return not_found("Snippet")
note, permission = loaded
data = snippets_svc.snippet_to_dict(note)
# Forge-checked freshness (#2690) — same decoration the MCP pull gets.
# Forge-checked freshness — same decoration the MCP pull gets.
await snippets_svc.attach_live_body(note, data)
data["permission"] = permission
# Read the association as the OWNER: a shared reader isn't scoped to the
@@ -177,7 +177,7 @@ async def get_snippet_route(snippet_id: int):
await attach_usage([data])
# Opening the detail view IS a pull — the operator chose to look. Tagged
# apart from the MCP sources so "the agent reused it" and "a human read it"
# stay distinguishable; they mean different things for pruning (#2085).
# stay distinguishable; they mean different things for pruning.
record_pulled(user_id=uid, note_id=snippet_id, source="rest_snippet")
return jsonify(data)
@@ -212,7 +212,7 @@ async def update_snippet_route(snippet_id: int):
if updated is None:
return not_found("Snippet")
if data.get("system_ids") is not None:
# The CALLER, not owner_uid — see routes/lessons.py for the why (#4249).
# The CALLER, not owner_uid — see routes/lessons.py for the why.
await systems_svc.set_record_systems(uid, snippet_id, data["system_ids"])
out = snippets_svc.snippet_to_dict(updated)
out["systems"] = [
+1 -1
View File
@@ -449,7 +449,7 @@ async def _open_ledger(session, user_id: int, note_id: int) -> int:
)).scalars().all())
opened = 0
for pid in sorted(project_ids - answered):
# Rule 78: a promotion reaches only the projects its promoter could
# A promotion reaches only the projects its promoter could
# have written an answer into themselves.
if await access.can_write_project(user_id, pid):
session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed"))
+5 -5
View File
@@ -3,11 +3,11 @@
WHY THIS EXISTS
A reply is where the person the work is for finds out what happened, and they
were not there while it was done. Milestone 409 wrote the shapes that make a
reply readable to them — conclusion first, the shortest reply that carries the
answer, where the work stands — and put them in one bundled skill. A skill
arrives only when the agent decides to load it, so in a long session the shape
faded out of context exactly when replies were getting longer.
were not there while it was done. The shapes make a reply readable to them —
conclusion first, the shortest reply that carries the answer, where the work
stands. A skill arrives only when the agent decides to load it, so in a long
session a shape held in a skill fades out of context exactly when replies get
longer.
So the shapes are product content held here, on the server, and delivered by
the moments they belong to rather than by the agent remembering to ask:
+1 -1
View File
@@ -82,7 +82,7 @@ async def set_supersedes(
silent drop, because it is the one case where the caller might reasonably
believe they succeeded and be wrong in a way that matters — demoting someone
else's record out of their retrieval is damage you cannot see from the
outside. Rule #47.
outside.
"""
if not await access.can_write_note(user_id, note_id):
return None