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.