Metamodel + knowledge bundles#50
Open
jimador wants to merge 49 commits into
Open
Conversation
12bd5e6 to
a930a0e
Compare
691debd to
11fdd8a
Compare
Versions the schema and guards it against silent drift. - MetamodelVersion, MetamodelDiff, and MetamodelDiffer (Javers-backed) version the schema and diff one version against another - DriftQuarantinePolicy with MentionTypeDriftQuarantinePolicy quarantines a mention type the current metamodel doesn't know about, instead of letting it drift the schema silently - MetamodelConfiguration ties it together Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Exports a slice of knowledge to a portable bundle and imports it back, locally. - KnowledgeBundle with KnowledgeBundleExporter / KnowledgeBundleImporter, both Jackson-backed - the import result reports what was added versus skipped Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Test-only coverage that pairs with the lifecycle, provenance, and agent surfaces. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Add docs/design/metamodel-governance.md (versioned schema stamps, additive-vs-lossy diffing, and non-destructive drift quarantine) and docs/design/knowledge-bundles.md (carrying the full proposition so a reloaded fact keeps its decay anchor, and treating an incoming bundle as untrusted). Add module AGENTS.md for dice-metamodel and dice-bundle, and list them in the root guide. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…dle paths Wire SLF4J loggers through the bundle export and metamodel diff/wiring seams: - bundle: log export proposition counts and context - metamodel: log diff result counts (added/removed/modified types and relationships) and which default policy implementations get wired Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
JacksonKnowledgeBundleImporter guarded the import size with serialised.length, which counts UTF-16 code units. The constant, the log message, and the failure reason all say "bytes", so a bundle of multi-byte characters could be up to ~2x the intended limit yet still pass. Measure the real UTF-8 byte length instead. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
This branch adds a dedicated DecayStatusPolicyTest in the common test package. Now that DecayStatusPolicy lives in spi, mirror its test there too so it accesses the type same-package, matching the other spi tests. No assertion changes. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The metamodel-governance and knowledge-bundles design notes existed but weren't linked anywhere. Add them to the README design-notes index and the root navigation guide so the governance and transfer subsystems are discoverable. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Compare entity-type label and property sets directly in JaversMetamodelDiffer instead of a space-joined string projection. The string form could collapse two genuinely different sets (a label or property whose name contains the delimiter) into a false 'unchanged', silently dropping a lossy modification and skipping quarantine. Removes the now-dead TypeShapeSnapshot machinery. Merge same-named domain types by union when stamping a MetamodelVersion. A DataDictionary can hold two types that share a name but differ in shape, and associate() silently kept only the last — dropping the other's labels/properties from the content hash so their later removal would go undetected. groupBy + union keeps the fingerprint complete. Count an OVERWRITE that replaces a pre-existing proposition separately from a net-new import and leave a note for it, so an idempotency audit can see the destructive write. Dedup by id within a single bundle so a repeated id can't double-write or double-count; the repeat is skipped with a note. Document that SKIP_EXISTING's skip is best-effort under concurrent imports to the same store. Give the drift sweep's already-quarantined pass-through an observable trace: count it apart from genuinely-conforming propositions and surface the number in the summary log. Tests: differ delimiter-collision regression; same-named-type union merge; bundle overwrite counting and within-bundle id dedup; a round-trip test asserting the abstraction, pinning, and reinforcement fields survive; and a status assertion that a re-swept proposition stays STALE. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…cy-safe The bundle importer's SKIP_EXISTING path read the store then wrote, so two concurrent imports of the same id could both see 'absent' and both write, losing the skip. Add a saveIfAbsent primitive to the PropositionStore SPI: it writes only when the id is absent and returns null otherwise. The SPI default stays read-then-write (documented best-effort); the in-memory store overrides it with ConcurrentHashMap.putIfAbsent and the Drivine store runs the check and insert inside its existing stripe-locked transaction, both genuinely atomic. The event-emitting decorator forwards to the delegate's saveIfAbsent so the atomic guarantee survives the decorator and a single PropositionPersisted fires only on a real insert. The importer's SKIP_EXISTING path now writes through saveIfAbsent. Tests: a cross-backend contract test (run against both the in-memory and the Neo4j store) proving insert-once-by-id; an in-memory concurrency test proving exactly one writer wins a contended id; and decorator tests proving saveIfAbsent emits on insert and stays silent on a skip. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The test was disabled because the runtime wiring that fires a trigger-bound @action was considered unverifiable here. It is verifiable and deterministic: build the agent straight from the annotated bean with AgentMetadataReader, drop a PropositionPersisted on the blackboard as the last result, and let the in-process GOAP planner run the single triggered action — no LLM, no threads. Adding @AchievesGoal gives the planner a goal to plan toward (the trigger precondition is excluded from the goal, so it stays achievable once the action runs). Now asserts the action actually fired. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…criptors diffAgainstObserved regex-parsed the bare relationship type name out of MetamodelVersion's rendered "From-[name]->To" descriptors. Since these names are free-text/LLM-derived, a name can itself contain a "-[...]->"-shaped substring, and the greedy regex would backtrack to the wrong segment, silently misclassifying drift. Change DeclaredObservedDiffer.diffAgainstObserved to take the declared bare relationship type names as an explicit parameter instead of recovering them by parsing. Callers hold the un-rendered relationship definitions before they're stamped into a MetamodelVersion and can supply the set directly, no lossy round-trip required. Also drop ObservedSchema.entityTypeCounts: nothing reads it and the quarantine runner keys off type names only, so it was untested dead data rather than a real requirement. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Add exportScoped(contextId) and exportAllContexts() to export propositions from the repository filtered by context. The exporter reads only the specified context via findByContextIdValue and assembles one bundle per context. All propositions in memory per context (no streaming). - exportScoped() returns a single KnowledgeBundle for a given context ID - exportAllContexts() returns one bundle per distinct context in the store - empty contexts return valid empty bundles with correct contextId - existing serialization methods unchanged; all round-trip tests pass Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
… drift diff (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…RITE honest Import gains an optional targetContextId so a multi-user caller can restore a bundle into a different workspace than the one it was exported from, instead of only ever re-importing into its original context. Conflict detection (SKIP_EXISTING/OVERWRITE) now runs against the target context: since PropositionStore keys by id alone, an id already used by a *different* context is a real storage-level collision and is rejected rather than silently relocated or overwritten. OVERWRITE was already non-atomic (findById + save, no store primitive ties them together); there's no transactional overwrite primitive to add without touching the PropositionStore SPI, which is out of scope here. Instead the policy's contract is spelled out as best-effort explicitly, and ImportResult now carries overwriteFailures so a caller can reconcile ids that failed mid-overwrite instead of assuming the store ended up in either the old or new state. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ics (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Per inspection findings, move context-scoped bundle assembly out of the KnowledgeBundleExporter SPI (which is pure serialization) into a new KnowledgeBundleAssembler collaborator class. Exporters stay focused on format serialization; callers compose by assembling bundles, then exporting. Fixes: - Blocking (F1): Use findByContextId(ContextId) instead of the unoptimized findByContextIdValue fallback, ensuring backend-pushed filtering works - High (F2): Remove repository-access methods from pure-serialization SPI - Medium (F3): No more nullable-sentinel defaults; assembly is explicit New KnowledgeBundleAssembler takes PropositionRepository as a constructor parameter and provides: - forContext(contextId): assemble single context bundle (uses optimized findByContextId for backend filtering) - allContexts(): assemble one bundle per distinct context Tests adapted to target the assembler. Added read-narrowness test verifying forContext never triggers full-store scan fallback. Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Add a durable store for metamodel versions and drift reports: - MetamodelStore SPI in dice-metamodel (no graph deps) with methods to save/query versions and drift observations by schema name - DriftReport data class captures observed undeclared types at a point in time - DrivineMetamodelStore implementation in dice-storage using Neo4j with: - MetamodelVersion nodes MERGED on (schemaName, contentHash) - MetamodelDriftReport nodes MERGED on (schemaName, versionHash, capturedAt) - Type sets serialized as tab/newline-delimited strings for robust round-trip - Results ordered by timestamp DESC in Cypher (not in memory) - RowMappers handle serialization/deserialization with fallback to EPOCH - Tests cover round-trip, idempotency, ordering newest-first, schema isolation Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…oped reads (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…edAt on re-save Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…rt store (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ort consumer SPIs Bundles can now optionally carry entity snapshots (data a consuming app owns for the opaque resolvedId strings on mentions) and embedding vectors (base64 float32, avoiding a re-embed at LLM cost on import). Both sections are additive and default-empty: a bundle without them stays valid under formatVersion "2.0", and dice keeps working standalone via null-object port defaults. - EntitySnapshotPort / EmbeddingPort (dice-bundle): export + import fun interfaces, each with a NoOp default. - KnowledgeBundle gains `entities` and `embeddings` (default empty) sections. - KnowledgeBundleAssembler takes optional exporter collaborators and populates the sections when wired. - JacksonKnowledgeBundleImporter takes optional importer collaborators (nullable, not defaulted) so a bundle carrying a section with no port wired is recorded as a note rather than silently dropped. Entities import before propositions; embeddings import after. - DrivineEmbeddingAccess (dice-storage) implements EmbeddingPort against the Proposition node's own embedding property in Neo4j, reusing DrivinePropositionRepository's VECTOR_INDEX_LABEL/PROPERTY constants. - dice-storage now depends on dice-bundle for the EmbeddingPort SPI. Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…h dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ation Wires drift detection end to end: DriftCheckRunner snapshots a live graph via ObservedSchemaSource, diffs it against a DeclaredSchemaSource, always persists a DriftReport (including zero-drift runs), and delegates quarantine to the existing DriftQuarantinePolicy on a non-dry run. MetamodelAutoConfiguration wires this behind embabel.dice.metamodel.enabled (default off, dryRun default on), with DrivineObservedSchemaSource excluding dice's own bookkeeping labels from the observed schema. No scheduler is included; consumers call run() themselves. Adds the missing dice-metamodel and embabel-agent-rag-core dependencies to dice-storage-autoconfigure's pom -- the latter mirrors the same provided/non-transitive dependency dice-metamodel already needed for PropositionRepository's CoreSearchOperations supertype. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…toconfiguration (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…e-saves write fully
Drivine's GraphObjectManager caches a JSON snapshot of every view it saves or
loads, keyed by (view class, id), and diffs the next save against it to
decide which fields actually need writing ("optimized save"). delete() has
no way to evict that cache -- GraphObjectManager exposes no eviction API and
its SessionManager field is private -- so a proposition re-saved under an id
it had before being deleted got diffed against its own stale pre-delete
state: any field that happened to still match was treated as "unchanged" and
never written to what is actually a brand-new, empty node. Confirmed by
direct reproduction: delete(id) then saveIfAbsent(sameId, sameValues)
persisted a node with only `id` set.
This is exactly what bundle restore does (wipe a context, reimport a bundle
that preserves original proposition IDs), which is why BundleRoundTripIT
surfaced it.
Fix: doPersist now builds its merge statements against a throwaway,
always-empty SessionManager instead of GraphObjectManager's shared one, via
Drivine's own public GraphObjectMergeBuilder.forClass(...). An empty session
makes every id look "never seen before", which is exactly the full-write
path a proposition already gets on its first save -- this reuses Drivine's
real serialization and mention-cascade logic verbatim rather than
reimplementing it. Trade-off: every save now writes every field instead of
just the diff, an acceptable cost at this store's documented ~10-50K row
scale. setProvenance() still goes through the optimized path and carries the
same latent risk if it turns out to need the same treatment later.
Also fixes a real precision bug found while chasing this down:
JacksonKnowledgeBundleExporter serialized Instant as a numeric
epoch-seconds-plus-fraction, which Jackson's jsr310 module parses back
through a double -- losing nanosecond precision at real-world epoch
magnitudes (a roundtrip of ...123456789Z came back ...123456700Z). Disabling
WRITE_DATES_AS_TIMESTAMPS switches it to ISO-8601 strings, which parse back
exactly -- the same fix Drivine's own Neo4jObjectMapper already applies for
the identical reason.
Adds a focused regression test pinning delete-then-resave-same-id behavior
directly against the repository, independent of the bundle import path.
Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…bundle backup/restore Relocated from dice-integration-tests, where the Drivine testcontainer harness never registered the 'neo' database (attempt 1's blocker); reuses dice-storage's own TestApplication, already proven by DrivineEmbeddingAccessIntegrationTest and DrivineMetamodelStoreIntegrationTest. Named *IntegrationTest, not *IT, to match this module's surefire naming convention -- an *IT-suffixed file is silently skipped by a plain `mvn test` here since there's no failsafe wiring. Covers the full backup/restore roundtrip: scoped export excludes other contexts, export/reimport fidelity (ids, text, contextId, confidence, decay, status), byte-identical decay-anchor timestamps, embeddings restored via DrivineEmbeddingAccess without re-embedding, mentions with resolvedIds, ctx-B isolation, targetContextId re-scoping into a fresh context, and delimiter-adversarial text/mention spans (pipes, tabs, newlines, quotes). Two test-file bugs fixed along the way: the importer wasn't wired with an embeddingImporter, so embeddings were silently never re-imported; and the re-scoping test asserted an impossible scenario (the same proposition ids live under both the original and the target context at once, which PropositionStore's global id-uniqueness correctly refuses) -- fixed to delete the source rows before re-scoping, matching a real migrate-into-a-new- context restore. All 9 tests pass against a live Neo4j testcontainer, now that the session- snapshot bug (previous commit) no longer corrupts re-saved rows. Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ve right after a delete Inspection caught a real regression in the previous fix: routing EVERY save through an always-empty Drivine SessionManager (saveFully) repaired the delete-then-resave-same-id staleness bug, but broke DELETE_ORPHAN for every other save. GraphViewMergeBuilder derives its relationship diff from the session snapshot -- no snapshot means every current relationship reads as "added", never "removed" -- so an ordinary re-save that shrinks a proposition's mentions silently orphaned the stale Mention node instead of deleting it. Reproduced live by the inspector; reproduced again here with a permanent regression test. Checked Drivine 0.0.58's actual sources first for a cleaner fix: no eviction API exists at any level. GraphObjectManager.delete() never touches the session; SessionManager is a private field with no getter; SessionManager itself only offers a whole-session clear(), no per-id form. There is nothing to call. Fix: track ids this repository instance has delete()d in a JVM-local ConcurrentHashMap-backed set (staleAfterDelete). doPersist consults it and consumes it one-shot: only the very next save for a just-deleted id goes through saveFully's always-full-write path; every other save goes straight through graphObjectManager.save, which is session-aware and correctly computes DELETE_ORPHAN's removed-relationship set. JVM-local is the correct scope, not a shortcut: the staleness being tracked IS JVM-local in-memory state, since Drivine's SessionManager lives inside this process's GraphObjectManager bean. Rewrote doPersist's and saveFully's KDoc to describe both paths truthfully instead of the now-false "DELETE_ORPHAN is always authoritative" claim. Adds `save with fewer mentions removes stale mention relationship` (DrivinePropositionStoreIntegrationTest embabel#11): save with 2 mentions, re-save the same id with 1 mention (no delete involved), assert exactly 1 mention and no orphaned Mention node in the graph. Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…tence fix (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Extend both design docs with component/sequence diagrams, a full format spec (with a verified sample bundle) for knowledge-bundles.md, and the new declared-vs-observed drift-check layer (ObservedSchemaSource, DriftCheckRunner, DriftReport persistence, bookkeeping-label exclusion, autoconfigure properties) for metamodel-governance.md. Fix dice-bundle/AGENTS.md references to the old 1.0 format and pre-entities/embeddings ImportResult shape. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
knowledge-bundles.md referenced a "Full-proposition fidelity" heading dropped during the rewrite; replace with inline rationale. Also fold in the dice-metamodel/AGENTS.md extension covering the new declared-vs-observed drift-check layer (ObservedSchema/Source, DeclaredSchema/Source, DriftReport, MetamodelStore, DriftCheckRunner), consistent with metamodel-governance.md. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ec, consumer samples (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Null context keeps whole-graph behavior; a non-null context confines the check end to end — candidate propositions, schema observation, the persisted drift report, and quarantine blast radius — to one context. Scoped observation derives entity types from that context's own mention types; the scoped relationship set is intentionally empty (no honest per-context attribution yet). Adds the context-scoped section + updated diagrams to the design doc. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…GE key The natural key (schemaName, versionHash, capturedAt) excluded contextId, so a scoped report and a global/other-context report for the same schema+version landing on the same capturedAt instant silently overwrote each other -- exactly the canary + multi-tenant-batch path this feature exists for. Add a contextKey property (the real contextId when scoped, a sentinel when global) to the MERGE match pattern. contextKey has to be a property distinct from the nullable contextId itself, since Cypher property-map equality on a literal null never matches -- merging directly on a nullable contextId would make every global report take the CREATE branch and duplicate on every retry instead of updating in place. Adds an integration test asserting a null-context and a tenant-context report at the exact same instant coexist as two nodes, and updates the test schema's uniqueness constraint and the design doc's natural-key description to match. Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…(dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
A semicolon inside a Note text is a mermaid statement separator and broke the diagram render; use a dash instead. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ift API DriftReport.contextId and MetamodelStore.driftReports(schemaName, contextId) now carry ContextId instead of a raw String, closing the asymmetry m5 left between DriftCheckRunner (already ContextId) and the persistence layer. The Neo4j property is still a plain string (bound via ContextId.value on write, wrapped back on read) and the contextKey MERGE-sentinel logic is unchanged. Also adds the idempotency test flagged Low in the m5 re-review: re-saving an identical drift report (null and tenant-scoped variants) MERGEs in place instead of duplicating. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…API (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ip set via sourcePropositions join DrivineObservedSchemaSource's context-scoped observation always reported an empty relationship-type set, because there was no honest way to attribute a relationship edge to a context -- dice didn't write anything a scoped query could join against. That's no longer true: every edge carries a `sourcePropositions` property (the ids of the propositions that produced it), so a relationship type belongs to a context's scoped set if at least one edge of that type has a sourceProposition whose owning Proposition has that contextId. This deliberately does not collapse multi-context edges: an edge sourced from two different contexts' propositions appears in both contexts' scoped sets, since an undeclared relationship type present in a context's data is drift in that context regardless of who else also produced it. No write-side changes -- this is a read-only join added to the existing scoped observation query. Adds real Neo4j testcontainer infrastructure to dice-storage-autoconfigure (it had none) so the join, the no-collapse behavior, and delimiter/case-identity edge cases can be proven against a live graph rather than asserted against a fake PersistenceManager that only echoes back canned rows. Claude-Session: https://claude.ai/code/session_01LmaqyBmfyCKu6P8icneoaT Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…rcePropositions join (dish dice-pr50-replan) Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
drivine4j 0.0.58 pins its testcontainer to neo4j:5.26.1-community, which has a dynamic-relationship-type query-plan-cache bug (neo4j/neo4j#13597) that silently drops a relationship type on the second write. Point the ITs at a self-managed Neo4jContainer on 2026.05-community (where the bug is fixed) via test.neo4j.use-local + @DynamicPropertySource, so we control the version rather than inherit the pinned one. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Metamodel + knowledge bundles
Last of the four-PR stack. Stacked on #49 — review after #47–#49 merge; until then the diff
includes their commits.
Carves out two modules:
dice-metamodel,dice-bundle.What's in it
metamodel so each version can be diffed against the last, and quarantines mention-types that drift
from the model for review instead of letting them silently land in the graph.
so a graph (propositions, provenance, and decay status) can be written out and reloaded faithfully
in another instance.
Importer hardening
The Jackson bundle importer's parse path was tightened so untrusted input can't slip past the guards:
formatVersionis rejected through the version gate rather than coercedto the current version.
nullinput returns a cleanParseFailureinstead of throwing.maxBundleBytescap now applies to all three import paths (importFromString,importFromStream,importFromReader); the stream and reader paths are bounded mid-read, and thereader caps by UTF-8 byte length rather than character count.
No pre-existing design issues map to this PR.
Closes #45
Closes #46