@terpjs/spec 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.19.0
1
+ 0.21.0
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "backend/emitted_events_are_declared",
3
+ "surface": "backend",
4
+ "title": "A module emits only the events its manifest declares",
5
+ "intent": "The manifest's emits list is the module's published contract: it is what the control plane validates, what an operator reads to know what a module produces, and what another team subscribes against. An emit the manifest never declared makes that contract quietly untrue — the event really does go out, so nothing fails, while the document everyone reasons from says it cannot happen. The rule compares every event constant an emit call or lifecycle event map names inside a module package against that module's declared emits.",
6
+ "layer": "static-bespoke",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "terp.arch",
11
+ "ref": "check_emitted_events_are_declared"
12
+ }
13
+ ],
14
+ "reference": "ModuleSpec(emits=[...]) in modules/<name>/module.py, compared against emit(event=...) and LifecycleEventMap(created=/updated=/deleted=...) references in the same module package.",
15
+ "opt_out": "# arch-allow-emitted-events-are-declared: <reason>",
16
+ "runtime": {
17
+ "applicability": "not-applicable",
18
+ "rationale": "emit() carries no module identity at the call site, so the running system cannot attribute an emit to the manifest that should have declared it; the association exists only in the source layout (which module package the call lives in). Adding a module handle to emit() to make it runtime-checkable would put the answer in the caller's hands, which is exactly what the rule is verifying."
19
+ },
20
+ "guide_topic": "events",
21
+ "corpus": true
22
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "backend/forwarded_filters_are_declared",
3
+ "surface": "backend",
4
+ "title": "Every filter name a read endpoint forwards must be a declared filter",
5
+ "intent": "An endpoint that narrows a read forwards its optional query parameters unchanged, so a filter name that matches no declaration carries no value on any request that omits that parameter. The narrowing appears to be applied while the read stays unnarrowed, and no test that omits the parameter can observe the difference. Each forwarded filter name must correspond to a filter the read layer declares, so a name that no declaration backs is rejected on the source rather than on the one request that happens to supply a value for it.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "terp.arch",
11
+ "ref": "check_forwarded_filters_are_declared"
12
+ },
13
+ {
14
+ "kind": "runtime",
15
+ "tool": "terp.core",
16
+ "ref": "resolve_filters"
17
+ }
18
+ ],
19
+ "reference": "A literal filter name forwarded from an endpoint that matches no declared filter is flagged at that name, and the message lists the declared names so a misspelling is visible against its intended target. Names that are not statically knowable — a filter mapping built elsewhere, or a computed name — are not judged, because a guess there would reject correct code.",
20
+ "opt_out": "# arch-allow-forwarded-filters-are-declared: <reason>",
21
+ "runtime": {
22
+ "applicability": "required",
23
+ "rationale": "The read layer resolves each forwarded name against the declarations while serving the request, and rejects an undeclared name before the read is built, so the invariant is observable and enforced fail-closed at runtime. The name is resolved before an absent value is discarded — checking the value first would leave an undeclared name both unreachable and unreported for as long as callers omit it, which is the defect this rule exists for. The build-time check is the earlier half: it holds for endpoints no request has exercised."
24
+ },
25
+ "guide_topic": "service",
26
+ "corpus": true
27
+ }
@@ -0,0 +1,13 @@
1
+ from control_plane.events import NOTE_CREATED, NOTE_DELETED
2
+ from terp.core import ModuleSpec, Policy, Roles, emit
3
+
4
+ spec = ModuleSpec(
5
+ name="notes",
6
+ router=router,
7
+ policy=Policy(read=Roles.VIEWER, write=Roles.EDITOR),
8
+ emits=[NOTE_CREATED, NOTE_DELETED],
9
+ )
10
+
11
+
12
+ def archive(session, note):
13
+ emit(session, event=NOTE_DELETED, payload={"id": str(note.id)})
@@ -0,0 +1,14 @@
1
+ from control_plane.events import NOTE_CREATED, NOTE_DELETED
2
+ from terp.core import ModuleSpec, Policy, Roles, emit
3
+
4
+ spec = ModuleSpec(
5
+ name="notes",
6
+ router=router,
7
+ policy=Policy(read=Roles.VIEWER, write=Roles.EDITOR),
8
+ emits=[NOTE_CREATED],
9
+ )
10
+
11
+
12
+ def archive(session, note):
13
+ # Declared nowhere: the manifest above says this module emits only NOTE_CREATED.
14
+ emit(session, event=NOTE_DELETED, payload={"id": str(note.id)})
@@ -0,0 +1,5 @@
1
+ def list_notes(session, author_id=None, created_from=None):
2
+ return service.list(
3
+ session,
4
+ filters={"author_id": author_id, "created_from": created_from},
5
+ )
@@ -0,0 +1,9 @@
1
+ from terp.core import BaseService, FilterField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (
7
+ FilterField("author_id", Note.author_id),
8
+ FilterField("created_from", Note.created_at, op="gte"),
9
+ )
@@ -0,0 +1,5 @@
1
+ # A filter mapping built elsewhere, and a computed name, are not statically
2
+ # knowable. Neither is judged: a guess there would reject correct code.
3
+ def list_notes(session, chosen, prepared):
4
+ service.list(session, filters=prepared)
5
+ return service.list(session, filters={chosen: 1})
@@ -0,0 +1,7 @@
1
+ # "created_form" matches no declared filter, so it never narrows the read — and
2
+ # stays silent for every request that omits the parameter.
3
+ def list_notes(session, author_id=None, created_from=None):
4
+ return service.list(
5
+ session,
6
+ filters={"author_id": author_id, "created_form": created_from},
7
+ )
@@ -0,0 +1,9 @@
1
+ from terp.core import BaseService, FilterField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (
7
+ FilterField("author_id", Note.author_id),
8
+ FilterField("created_from", Note.created_at, op="gte"),
9
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/spec",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "The Terp Standard — stack-neutral rule catalog, violation corpus, finding format, and refused-surface declaration (ADRs 0080/0081; packaged per ADR 0082, published per ADR 0086). Data only: consumers resolve the spec root via require.resolve('@terpjs/spec/package.json').",
5
5
  "files": [
6
6
  "VERSION",