@terpjs/spec 0.26.0 → 0.27.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.
Files changed (17) hide show
  1. package/VERSION +1 -1
  2. package/catalog/backend/declared_read_controls_are_forwarded.json +22 -0
  3. package/catalog/backend/frozen_values_hold_no_mutable_collection.json +22 -0
  4. package/corpus/backend/declared_read_controls_are_forwarded/compliant-01/modules/notes/router.py +8 -0
  5. package/corpus/backend/declared_read_controls_are_forwarded/compliant-01/modules/notes/service.py +7 -0
  6. package/corpus/backend/declared_read_controls_are_forwarded/compliant-02/modules/notes/router.py +6 -0
  7. package/corpus/backend/declared_read_controls_are_forwarded/compliant-02/modules/notes/service.py +7 -0
  8. package/corpus/backend/declared_read_controls_are_forwarded/violation-01/modules/notes/router.py +6 -0
  9. package/corpus/backend/declared_read_controls_are_forwarded/violation-01/modules/notes/service.py +9 -0
  10. package/corpus/backend/declared_read_controls_are_forwarded/violation-02/modules/notes/router.py +6 -0
  11. package/corpus/backend/declared_read_controls_are_forwarded/violation-02/modules/notes/service.py +6 -0
  12. package/corpus/backend/frozen_values_hold_no_mutable_collection/compliant-01/modules/notes/values.py +12 -0
  13. package/corpus/backend/frozen_values_hold_no_mutable_collection/compliant-02/modules/notes/values.py +9 -0
  14. package/corpus/backend/frozen_values_hold_no_mutable_collection/violation-01/modules/notes/values.py +15 -0
  15. package/corpus/backend/frozen_values_hold_no_mutable_collection/violation-02/modules/notes/values.py +13 -0
  16. package/layout-declaration.schema.json +78 -0
  17. package/package.json +2 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.0
1
+ 0.27.0
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "backend/declared_read_controls_are_forwarded",
3
+ "surface": "backend",
4
+ "title": "A declared filter or sort must be reachable from an endpoint that forwards it",
5
+ "intent": "A read layer declares which fields a caller may narrow or order by, and an endpoint reaches those declarations by forwarding the caller's request into the read. A declaration no endpoint forwards is inert: the capability is described in the source, absent from the API, and silent about the difference. The failure is worse than an unimplemented feature because it presents as an implemented one — a client generated from the contract offers no way to sort, a screen built against it ships with every column's sorting disabled, and nothing in the read layer is wrong. Each declared filter and sort must therefore be reachable: a module that declares one and forwards none is rejected on the source, rather than discovered by a user who cannot order a list the code says is orderable.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "terp.arch",
11
+ "ref": "check_declared_read_controls_are_forwarded"
12
+ }
13
+ ],
14
+ "reference": "Judged per module and per control kind by PRESENCE, never by name: a module that declares a filter is required to forward a filters mapping somewhere within itself, and a module that declares a sort is required to forward a sort. The keyword is visible in the source even when the value handed to it is computed, which is what makes presence decidable where a per-name comparison is not — a mapping built elsewhere hides its names but not its existence, so requiring a specific declared name to appear at a call site would reject correct code. The pairing of one declaration to one endpoint is not statically knowable either, which is why the unit is the module that owns both.",
15
+ "opt_out": "# arch-allow-declared-read-controls-are-forwarded: <reason>",
16
+ "runtime": {
17
+ "applicability": "not-applicable",
18
+ "rationale": "There is nothing for a runtime check to observe. The defect is the ABSENCE of a call: a request that would have carried a filter or a sort never arrives, because the endpoint exposes no parameter for it, so no code path runs and no fail-closed control can fire. This is a build-time-only invariant by nature, not by omission — the counterpart rule that judges a forwarded name against the declarations is enforced at runtime precisely because there a request does reach the read layer."
19
+ },
20
+ "guide_topic": "service",
21
+ "corpus": true
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "backend/frozen_values_hold_no_mutable_collection",
3
+ "surface": "backend",
4
+ "title": "A frozen value object must not hold a mutable collection",
5
+ "intent": "Freezing a value object announces that it cannot change after construction, and callers rely on that: they share it between requests, cache it, use it as a registry key, and skip defensive copies because the type says none are needed. Freezing binds the ATTRIBUTES, not what they point at — a frozen object holding a list or a dict or a set is immutable only one level deep, so anyone holding it can append to its contents while every guarantee the type advertises still appears to hold. The mutation is invisible at the call site that performs it and arrives somewhere else entirely, in another request or another worker, as state that changed without an assignment. A frozen field must therefore be typed as an immutable sequence or mapping, so the promise the type makes is the promise it keeps.",
6
+ "layer": "static-portable",
7
+ "enforcement": [
8
+ {
9
+ "kind": "build-time",
10
+ "tool": "terp.arch",
11
+ "ref": "check_frozen_values_hold_no_mutable_collection"
12
+ }
13
+ ],
14
+ "reference": "Flagged on the field of a frozen value object whose annotation is a mutable built-in collection — list, dict or set, including their typing spellings and parameterised forms — with the immutable counterpart named in the message: a tuple for a sequence, a Mapping or frozen mapping for a dict, a frozenset for a set. A frozen object is recognised by its declaration, not by convention: a dataclass frozen at the decorator, a NamedTuple, or a model configured immutable. An annotation that is not statically a mutable collection is not judged, because the shape of an aliased or computed type is not knowable here and guessing would reject correct code.",
15
+ "opt_out": "# arch-allow-frozen-values-hold-no-mutable-collection: <reason>",
16
+ "runtime": {
17
+ "applicability": "not-applicable",
18
+ "rationale": "A runtime control would have to intercept mutation of an object the frozen value merely references, which is the thing the language does not offer: the list belongs to whoever else holds it, its mutating methods are not the frozen object's to override, and a deep-freeze on construction would change the value's semantics rather than check them. This is the shape of invariant only a build-time reading of the declaration can hold — which is exactly why declaring a frozen field as immutable is the control, rather than watching what happens to it later."
19
+ },
20
+ "guide_topic": "module",
21
+ "corpus": true
22
+ }
@@ -0,0 +1,8 @@
1
+ # Both declarations are reachable: the endpoint exposes a parameter for each and
2
+ # forwards it, so what the read layer says is narrowable and orderable is.
3
+ def list_notes(session, author_id=None, sort=None):
4
+ return service.list(
5
+ session,
6
+ filters={"author_id": author_id},
7
+ sort=sort,
8
+ )
@@ -0,0 +1,7 @@
1
+ from terp.core import BaseService, FilterField, SortField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (FilterField("author_id", Note.author_id),)
7
+ sortable = (SortField("created_at", Note.created_at),)
@@ -0,0 +1,6 @@
1
+ # The mapping is built rather than written out, and that is still compliant: the
2
+ # rule judges whether the module forwards a filters mapping at all, never which
3
+ # names the mapping happens to contain. A computed mapping hides its names from a
4
+ # reader of the source; it does not hide its existence.
5
+ def list_notes(session, **query):
6
+ return service.list(session, filters=_requested_filters(query), sort=query.get("sort"))
@@ -0,0 +1,7 @@
1
+ from terp.core import BaseService, FilterField, SortField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (FilterField("author_id", Note.author_id),)
7
+ sortable = (SortField("created_at", Note.created_at),)
@@ -0,0 +1,6 @@
1
+ # Two sorts are declared and no endpoint forwards one, so the read is never
2
+ # ordered by anything a caller asked for. Nothing here is wrong and nothing
3
+ # fails: a client generated from this contract simply has no way to sort, and a
4
+ # screen built on it ships with every column's sorting disabled.
5
+ def list_notes(session, author_id=None):
6
+ return service.list(session, filters={"author_id": author_id})
@@ -0,0 +1,9 @@
1
+ from terp.core import BaseService, SortField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ sortable = (
7
+ SortField("name", Note.name),
8
+ SortField("created_at", Note.created_at),
9
+ )
@@ -0,0 +1,6 @@
1
+ # The same absence on the filter side: a declared filter that no endpoint
2
+ # forwards describes a narrowing the API cannot perform. The list is always
3
+ # unnarrowed, which is also the shape a reader is most likely to mistake for a
4
+ # permission problem rather than a missing parameter.
5
+ def list_notes(session, sort=None):
6
+ return service.list(session, sort=sort)
@@ -0,0 +1,6 @@
1
+ from terp.core import BaseService, FilterField
2
+
3
+
4
+ class NoteService(BaseService):
5
+ model = Note
6
+ filterable = (FilterField("author_id", Note.author_id),)
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+ from collections.abc import Mapping
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class ImportPlan:
7
+ """Frozen all the way down: the promise the type makes is the one it keeps."""
8
+
9
+ name: str
10
+ columns: tuple[str, ...]
11
+ defaults: Mapping[str, str]
12
+ tags: frozenset[str]
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ # Not frozen, so nothing is promised and a list is an honest field. The rule is
5
+ # about a claim that does not hold, never about mutability itself.
6
+ @dataclass
7
+ class ImportDraft:
8
+ name: str
9
+ columns: list[str]
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class ImportPlan:
6
+ """Immutable one level deep, which is the level nobody checks.
7
+
8
+ `plan.columns.append("x")` succeeds on a frozen object. Callers share this
9
+ between requests and skip defensive copies because the type says they can,
10
+ so the mutation arrives somewhere else entirely as state that changed with
11
+ no assignment anywhere near it.
12
+ """
13
+
14
+ name: str
15
+ columns: list[str]
@@ -0,0 +1,13 @@
1
+ from typing import NamedTuple
2
+
3
+
4
+ class RouteSpec(NamedTuple):
5
+ """A NamedTuple is frozen by construction, and holds a mutable dict anyway.
6
+
7
+ Worse than the dataclass case in practice: a tuple is the shape people reach
8
+ for precisely BECAUSE it is safe to use as a key or share freely, so the
9
+ false guarantee travels further.
10
+ """
11
+
12
+ path: str
13
+ handlers: dict[str, str]
@@ -0,0 +1,78 @@
1
+ {
2
+ "title": "Terp Standard - the app's layout declaration",
3
+ "description": "The normative, stack-neutral schema for the document an app checks in to declare its layout: which slot-typed page contract it opts into, which palette it starts on, and how its application shell is shaped. One document, so a build-time checker and a running app read the same bytes and neither can hold a different answer - and so a tool that edits files, rather than code, can read and rewrite these choices. Naming the contract and naming a palette are the app's to do and the values are per-stack, hence plain strings; the shell's vocabulary is fixed here, because a density or a navigation placement means the same thing on any stack. Every key is optional: an absent key is not a default, it is the app declining to declare, and a consumer must leave whatever was already in force alone. A key a consumer does not recognise must be refused rather than ignored, or a declaration that does nothing looks like one that works.",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "contract": {
8
+ "type": "string",
9
+ "minLength": 1,
10
+ "description": "The named slot-typed page contract this app opts into. Which contracts exist, and which components each of their page slots admits, is per-stack configuration recorded non-normatively in the catalog entry's reference field - the portable part is that the opt-in is declared here, once, for every consumer that enforces it."
11
+ },
12
+ "defaultTheme": {
13
+ "type": "string",
14
+ "minLength": 1,
15
+ "description": "The palette the app starts on, until a person chooses another and for as long as they have not. Declared here rather than in the app's own code for the reason the shell's shape is: a choice reachable only by editing code is out of reach of anything that edits files, and which palette an app opens on is among the most visible choices its operator makes. Which palettes exist is per-stack configuration recorded non-normatively in the catalog entry's reference field, hence a plain string rather than an enum - the portable part is that the choice is declared here, once. The one reserved name is \"system\", which declares that the app opens on whatever light or dark preference the viewer's own platform reports; every other value names a palette the stack ships, and a consumer must refuse a name it does not ship rather than fall back to one it does."
16
+ },
17
+ "shell": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "description": "How the application shell is shaped. Separate from the contract above because that governs the inside of a page and this governs the frame around it. Everything in here has a vocabulary this schema fixes, which is what separates it from the two keys above: a density, a navigation placement, the parts of a navigation group and the parts of a brand mark all mean the same thing on any stack, while which page contracts and which palettes exist do not.",
21
+ "properties": {
22
+ "brand": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "description": "The app's own mark, as paths a consumer resolves the way it resolves any other static asset the app ships. Declared here for the reason everything else in this document is: a mark is among the most visible things about an app and it was reachable only by editing that app's source, which put it out of reach of anything that edits files. Both keys are optional, and an app that declares neither shows whatever placeholder its stack ships.",
26
+ "properties": {
27
+ "logo": {
28
+ "type": "string",
29
+ "minLength": 1,
30
+ "description": "The mark shown inside the app's own frame. A consumer sizes it to the frame rather than to itself, so an oversized asset is scaled down rather than clipped."
31
+ },
32
+ "logoDark": {
33
+ "type": "string",
34
+ "minLength": 1,
35
+ "description": "The mark for a dark palette, declared separately rather than derived. A mark with fixed colours cannot survive a dark background and no consumer can tell whether this one can, so an app that has a second asset says so and an app that does not says nothing and keeps one mark everywhere. A consumer that has both must choose by the appearance of the palette actually in force, not by the viewer's platform preference: an app may open on a dark palette on a light platform, and a mark chosen by the platform would then be the wrong one."
36
+ }
37
+ }
38
+ },
39
+ "contentWidth": {
40
+ "enum": ["full", "measured"],
41
+ "description": "Whether routed content is capped at the shell's declared reading measure or spans the full track."
42
+ },
43
+ "density": {
44
+ "enum": ["comfortable", "compact"],
45
+ "description": "The app-wide control height and cell padding, declared once for the whole shell rather than per screen."
46
+ },
47
+ "navGroups": {
48
+ "type": "array",
49
+ "description": "The navigation groups this app declares, which is the only place they can be declared: a group spans modules, so no module can own one. A module's navigation item names a group by id, an item naming a group this list does not declare is not an error but the normal state of an app mid-adoption, and every such item falls into a trailing group with no label. Declaring two groups with the same id is an authoring error a consumer must refuse.",
50
+ "items": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "required": ["id", "label"],
54
+ "properties": {
55
+ "id": {
56
+ "type": "string",
57
+ "minLength": 1,
58
+ "description": "The group's own name, referenced by a navigation item to say which group it belongs to. Non-empty: a consumer may well treat the empty string as a usable key at render time, where being total matters more than being strict, but declaring one is an authoring error with no legitimate transient form - it is a group nothing can name on purpose."
59
+ },
60
+ "label": {
61
+ "type": "string",
62
+ "description": "What a consumer renders above the group's list. The empty string declares a positioning-only group: no label element at all, which is how an app places otherwise ungrouped items somewhere other than the end without inventing a heading for them. Required rather than optional, so that having no label is a decision this document states rather than a key someone forgot."
63
+ },
64
+ "order": {
65
+ "type": "integer",
66
+ "description": "Sort key against sibling groups, ascending. Absent is the same as zero and the sort is stable, so groups that tie keep the order they are declared in - which means an app that wants only a sequence can write the groups in that sequence and set this on none of them. An integer, because a sort key is an ordinal."
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "navPlacement": {
72
+ "enum": ["header", "sidebar"],
73
+ "description": "Where the primary navigation sits on a wide viewport: a full-height rail beside the content, or a row within the header."
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/spec",
3
- "version": "0.26.0",
3
+ "version": "0.27.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",
@@ -8,6 +8,7 @@
8
8
  "app-check-report.schema.json",
9
9
  "scorecard.schema.json",
10
10
  "assurance-profile.schema.json",
11
+ "layout-declaration.schema.json",
11
12
  "restricted-surface.json",
12
13
  "catalog",
13
14
  "corpus"