Compare commits
4
Commits
0c237172e4
...
0c88e1b0b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c88e1b0b0 | ||
|
|
78e1bdbd93 | ||
|
|
6a9f02990c | ||
|
|
2a5276eb0e |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] = [
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user