Define the bundled-core and user-local storage boundary #59
No reviewers
Labels
No labels
area/deployment
area/governance
area/metadata
area/pipeline
area/search
type/data
type/design
type/implementation
type/testing
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Arkive/arkive!59
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feature/issue-45-storage-boundary"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
docs/storage-boundary.md, specifying the hard separation between the redistributable core and the user-local overlay: canonical locations, ownership, manifests and identifiers, the shared pipeline's distribution context, lifecycle handling, release and export behaviour, and container and portable deployments.distribution_scope: user_localfound underdata/— documents and chunks, not only local acquisition records..gitignoreentries for the conventional overlay root directories and the sentinel marker, deliberately excluding record filenames.tests/test_storage_boundary.pywith eight regression tests.Acceptance criteria
Section 9 of the document maps each criterion to its mechanism; summarised here.
.gitignorecovers a misconfigured root (§7.3), release tooling aborts on contamination (§7.1), and exports exclude the overlay by default (§7.2).(distribution_scope, identifier)pair, and every document and chunk record carriesdistribution_scope(§4.2).Verification
git diff --checkpython pipeline/validate/validate_records.py— PASS, committed corpus unchangedpython -m unittest discover -s tests -v— 150 tests passing, up from 142python -m ruff check src pipeline testspython -m ruff format --check src pipeline testsScope confirmation
Design decisions worth reviewing
The read-only core handle is the primary guarantee, not a policy statement. A process that never holds a writable handle to the core cannot write into it regardless of caller intent. Everything else in §3 and §5 is defence layered on top of that.
The distribution context is passed, never inferred. §5 states this as a rule with its reasoning: inference from a path, filename, or ambient state is precisely how a local artifact ends up treated as core. This is the constraint most likely to be eroded later by a convenience helper that "figures out" the domain, so it is written as a rule rather than a convention.
Identifiers are not renamed to avoid collision. Local artifacts keep the same
doc_/chk_patterns; a cross-boundary reference carries the domain alongside the identifier instead. Renaming would have made local records structurally different from core ones for no benefit and broken the shared pipeline's uniformity.The overlay stores a recipe snapshot, and this resolves a constraint created by PR #56. A local acquisition record pins the
recipe_versionit used, and the validator requires that version to match the referenced recipe. Comparing against the shipped catalogue would invalidate every existing acquisition record the moment Arkive revised a recipe. §2.4 therefore has the overlay retain a verbatim copy of the recipe at the version used, and local validation resolve against that snapshot. The shipped catalogue remains authoritative for what may be acquired next; the snapshot records what was acquired then.Stale indexes are excluded from search rather than queried. §6.4. A stale index returns citations that may point at text no longer at those offsets, and a wrong citation is worse than a missing result in a project whose central claim is that every answer resolves to a real passage.
A footgun caught during implementation
The first
.gitignoredraft listed bare record filenames (local_acquisition.json,recipe-snapshot.json).git check-ignore --no-indexconfirmed those patterns silently shadow the acquisition fixtures added in PR #56: the already-tracked file survives, but any new fixture with that name would be invisible togit add.The entries were narrowed to the conventional root directories and the sentinel marker only, and
GitignoreBoundaryTestsnow asserts that record filenames stay un-ignored while the marker stays ignored. The comment in.gitignorerecords why.Test approach
tests/test_storage_boundary.pypointsvalidator.DATA_DIRat a temporary tree rather than writing contamination fixtures into the realdata/. A fixture placed indata/would itself be the thing the guard exists to prevent, so the tests must not create one.Coverage includes both directions:
user_localdocuments, chunks, and acquisition records underdata/are rejected;bundled_corerecords, records omitting the field entirely, anduser_localrecords outsidedata/all pass. The overlay is legitimate — only its presence inside the core store is not.Note for a follow-up issue
Not addressed here, and out of scope under the tooling rule in
AGENTS.md: the test harness has accumulated some duplication worth consolidating. Three test modules bootstrap the validator throughspec_from_file_locationwhile five import the package normally, a fixture helper is borrowed across unrelatedTestCaseclasses,DATA_DIRis patched by hand, and there is no coverage measurement. The last one is the substantive gap — an unreachable validator rule was found by reasoning during the PR #56 review rather than by tooling, and at 838 lines with five rule families that question is no longer answerable by inspection. A separate issue will be raised.Linked issue
Closes #45
Thanks for the thorough implementation. The overall architecture matches Issue #45 and ADR-0002 well, and I agree with the main design choices: separate stores/manifests, explicit distribution context, domain-qualified references, retained recipe snapshots, stale-index exclusion, and keeping the later acquisition/index/release work in Issues #46–#50.
I found two items I would like corrected before merge:
1. Blocking: symlink escape in the core contamination check
_under_data_dir()currently uses:Because
Path.resolve()follows symbolic links, a metadata file whose directory entry is insidedata/but which is a symlink to a target outsidedata/is classified as outside the core. For example:would not trigger
_reject_local_content()solely on the basis of being auser_localrecord under the core tree.That weakens the hard-boundary/fail-closed guarantee this issue is intended to establish.
Please either reject symlinked core metadata records outright, or make the containment check account for both the path's location in the core namespace and its resolved target. Please also add a regression test covering a
user_localrecord reached through a symlink located underdata/.My preference is to reject symlinked metadata records in the bundled core unless Arkive has an explicit use case for supporting them.
2. Cross-platform permissions
docs/storage-boundary.mddefines a Windows local root under%LOCALAPPDATA%, but §3 universally specifies mode0700.0700is a POSIX permission model and does not provide the corresponding protection on Windows.Please make the architecture platform-specific here:
0700.The actual Windows implementation can remain downstream, but Issue #45 is defining the ownership/permission contract that implementation will follow.
Minor documentation cleanup: §9 says “every record carries
distribution_scope”. The schemas currently putdistribution_scopeon document and chunk records. I suggest matching the more precise language already used in §4.2: document/chunk records carry the scope, and cross-domain references carry(distribution_scope, identifier).Other than these points, I found the PR well scoped and consistent with the existing architecture. Once the symlink boundary and cross-platform permission language are corrected, I expect this to be ready for merge.
All three addressed in
a9034c4.1. Symlink escape — confirmed, and there were two of them.
The finding was right.
_under_data_dir()calledPath.resolve()before testing containment, andresolve()follows symbolic links, so a record whose directory entry sat insidedata/but whose target lay outside it was classified as outside the core and skipped the guard entirely. Demonstrated directly against the old and new logic:Containment is now judged by namespace as well as resolved target: a record counts as core when either its lexical location or its resolved target sits under the core root. A record's domain follows the namespace it is published in rather than wherever its bytes live, and for a boundary check the safe direction to fail is towards treating something as core.
Taking your stated preference, symbolic links under the core root are also rejected outright rather than followed. That turned out to matter for a second reason I did not anticipate: writing the regression test for a symlinked directory showed that
rglobdoes not descend into one, so records beneath it were never examined at all. Not a bad record passing validation — a whole subtree silently invisible. Both escapes are closed by the same rule.A third defect fell out of writing that test.
validate_tree()returned success as soon as no record files were discovered, before reporting accumulated problems. A tree whose only content was a symlinked directory therefore printed "No record files found" and exited zero, with the symlink problem already recorded and discarded. The early return is now taken only when there is genuinely nothing to report. That bug predates this PR but is squarely in the path this issue is about, so it is fixed here rather than deferred.Five regression tests: a
user_localrecord reached through a symlink, abundled_corerecord reached the same way, a symlinked parent directory, real core files still validating cleanly, and symlinks outside the core remaining untouched — the overlay is free to use them.2. Cross-platform permissions.
Section 3 now states the requirement as a contract — the local root must be private to the user running Arkive — with the mechanism given per platform: POSIX ownership plus mode
0700, and on Windows an ACL granting the owning account only, with inheritance disabled so a permissive parent ACL is not carried in. The text says explicitly that0700has no meaning on Windows and must not stand in for an ACL.I added one requirement you did not ask for, and will remove it if you would rather it waited for the implementation issue: an implementation must verify the protection it actually applied rather than assume the creation call achieved it, and must refuse to write local content into a root it could not make private. A silently world-readable local root is the failure this section exists to prevent, and it is the kind of thing that is easy to assume and rarely checked.
3.
distribution_scopewording.Section 9 now uses the section 4.2 phrasing: document and chunk records carry
distribution_scope, and cross-domain references carry the(distribution_scope, identifier)pair.Section 7.1 and the section 11 summary also now record the namespace-containment rule and the no-symlinks rule, so the document describes the boundary as it is actually enforced.
Validation
git diff --checkcleanruff checkandruff format --checkcleanOn sequencing: understood, and I agree with the ordering. #60 next once this merges, before the overlay implementation issues. Your refinements there are all sensible — branch coverage rather than line coverage, the corrected test-count criterion, uncovered branches being audited rather than assumed dead, real package imports rather than hiding the
spec_from_file_locationworkaround behind a helper, and a recorded baseline with no threshold. The symlink cases added here will be part of that audit.