Spaces decisions (SPC)#
Decisions behind spaces, sub-spaces and the overview pointer. For usage and configuration, see the spaces install docs.
Primary code: reporting/schema/space_config.py,
reporting/services/spaces.py, reporting/routes/spaces.py,
reporting/services/report_store/.
SPC-001 — Spaces are flat records with no version history and no access scope#
Applies to: reporting/schema/space_config.py
No /versions endpoints, and list_spaces is unfiltered.
Why: a space is a container, not content. Report-level visibility still
filters what list_space_reports returns, so the thing worth protecting is
protected without a second access model to keep consistent.
SPC-002 — A report filed in a space is public, enforced in the stores#
Applies to: SPACE_MEMBER_ACCESS, require_public_space_member
Filing a draft is a 409, unpublishing a member is a 409, and creating a report
into a space publishes it. All three writes are enforced in the stores:
require_public_space_member on create_report, a ConditionExpression on the
DynamoDB #METADATA put, and SELECT ... FOR UPDATE on SQL.
Why in the stores: the route-level check alone loses a concurrent
unpublish/file race. Both stores raise SpaceConflictError (defined in
schema/space_config.py so stores and services can share it), which routes map
to 409.
Why the invariant at all: a space holding a report nobody else can see would both block the space’s deletion invisibly and leak the report’s ID through the overview pointer.
Don’t: treat a first-item ConditionalCheckFailed as the only failure mode
— other cancellation reasons must propagate rather than becoming a 409.
SPC-003 — Membership is unversioned parent metadata#
Applies to: update_report_space
Reports carry nullable space_id/subspace_id, denormalized onto
ReportVersion but never written into a version item or into config.
Moves go through update_report_space — a metadata mutation like
update_report_visibility, not a version save — with replace semantics, so
omitting subspace_id clears it.
Why: if membership lived in the version, restoring an old version would relocate the report. Replace semantics are also what makes “moving to another space drops the sub-space” fall out for free.
SPC-004 — The overview is a lazily resolved nullable pointer#
Applies to: space.overview_report_id, GET /spaces/{id}/tree
The pointer targets one of the space’s own reports and is set via
PUT /spaces/{id}/overview. The target stays an ordinary report with no
protections. Resolution happens in GET /spaces/{id}/tree, which blanks it when
the target is deleted, moved out, or invisible to the caller.
Why lazy: it removes the need for any invariant on the target report. Nothing has to be enforced at write time because nothing downstream trusts the pointer.
Only the tree and PUT /spaces/{id}/overview (which echoes what the caller just
set) expose it. GET /spaces, GET /spaces/{id} and PUT /spaces/{id} blank
it, because outside the tree there is no report list to resolve it against.
SPC-005 — delete_space returns a result type, not a bool#
Applies to: SpaceDeleteResult
A deliberate break from the bool delete convention.
Why: “no such space” (404) and “still has reports” (409) are different answers and a bool cannot carry both. Emptiness is evaluated without visibility filtering — belt and braces, since members are public anyway (SPC-002).
Only reports block the delete. Sub-spaces cascade with the space, and no
report is ever deleted with a space. delete_subspace writes nothing to
member reports: their dangling subspace_id is normalized to None by the tree
endpoint, which beats an unbounded non-transactional fan-out.
SPC-006 — Every mutating space tool in chat is confirmation-gated#
Applies to: the spaces MCP builtin group
No chat_safe_without_confirmation exception, and a test asserts this so a new
tool cannot quietly opt out.
Why: a space is a shared, globally visible container, and filing publishes.
Filing a report is spaces__set_report_space — in the spaces group, so
disabling the group drops the whole feature, but keeping the route’s
reports:write permission. REST and MCP share the helpers in
services/spaces.py (duplicate-name guards, without_overview,
with_resolved_overview, with_resolved_subspace) specifically so they cannot
diverge on which responses carry the pointer.
SPC-007 — YAML matches spaces by name, and an omitted space never unfiles#
Applies to: seizu_schema/reporting_config.py, the seed path
Spaces and sub-spaces are matched by name on seed, like reports, since ids
are server-generated; YAML keys are local handles. The three cross-references
(report→space, report→sub-space, space overview→report filed in it) are
validated by a ReportingConfig model validator at load, so a typo fails before
any write.
space/subspace are seed metadata like pinned: applied via
PUT /reports/{id}/space after the version save and stripped from the stored
config (exclude={"pinned", "space", "subspace"}) — otherwise restoring a
version would relocate the report (SPC-003).
Seed order is spaces → reports → membership → overview. An omitted space
never unfiles a report (the pinned rule), and seeding deletes nothing.