Skip to main content
Glama
README.md
# ModuHaus — Australian Modular House Knowledge Base

A PostgreSQL-backed knowledge base covering everything a buyer or builder needs to
know about modular housing in Australia: planning regulation, finance, cost,
suppliers, contracts and construction. It is built to be consumed by an MCP server
and by interactive tools on the ModuHaus website (site feasibility lookup, budget
estimator, and so on).

The knowledge base is deliberately built **one module at a time**. The schema and
naming conventions below exist so that later modules drop into the same database
without restructuring anything.

---

## Design principles

**One database, many modules.** Everything lives in `modular_house_kb`. The name
describes the whole knowledge base, not any single feature — there is no
`granny_flat_db`. A single database keeps cross-module queries possible: a
feasibility check that combines a planning rule with a cost band and a supplier
lead time should be one SQL query, not three round trips.

**The table name carries the module.** Every table is prefixed with its module, so
`\dt` in psql groups the schema by domain and any reader can tell at a glance what
a table belongs to:

```
regulation_*     planning rules, permit pathways, DA process
finance_*        construction loan structures, lender policies, rates
cost_*           build cost ranges by spec and region
supplier_*       suppliers, product lines, specifications, reputation
contract_*       standard clauses, common dispute points
construction_*   timelines, transport and install, delay causes
```

**Rows are jurisdiction-scoped and source-stamped.** Australian modular housing
rules differ per state and change often. Every fact-bearing table carries the
jurisdiction it applies to plus `source` and `last_verified`, so stale content is
visible rather than silently wrong. Never store a regulatory number without the
instrument it came from.

---

## Current status

### Completed — Regulation module, granny flat subset

`regulation_granny_flat_rules` holds state-level approval standards for secondary
dwellings (granny flats, office pods).

| Column | Type | Notes |
| --- | --- | --- |
| `id` | SERIAL PK | |
| `state` | VARCHAR(10) | `NSW`, `VIC`, ... |
| `category` | VARCHAR(50) | `granny_flat` |
| `rule_type` | VARCHAR(100) | the specific rule being described |
| `max_floor_area_sqm` | NUMERIC | |
| `min_lot_size_sqm` | NUMERIC | |
| `default_permit_required` | BOOLEAN | whether approval is needed by default |
| `excluding_conditions` | TEXT[] | overlays/hazards that void the pathway |
| `fast_track_pathway` | VARCHAR(50) | e.g. `CDC` |
| `fast_track_days` | INTEGER | |
| `fallback_pathway` | VARCHAR(50) | e.g. `DA` |
| `fallback_max_days` | INTEGER | |
| `source` | TEXT | the planning instrument |
| `last_verified` | DATE | |

All eight jurisdictions are seeded. The headline numbers:

| State | Max area | Min lot | Permit by default | Instrument |
| --- | --- | --- | --- | --- |
| NSW | 60 sqm | 450 sqm | yes (CDC) | SEPP (Housing) 2021 |
| VIC | 60 sqm | 300 sqm | no | Amendment VC253 |
| QLD | — | — | — | set by each council |
| WA | 70 sqm | — | no | R-Codes Vol 1 |
| SA | 70 sqm | — | yes | Planning and Design Code |
| TAS | 60 sqm | — | no | State Planning Provisions |
| ACT | 90 sqm | 500 sqm | yes | Territory Plan 2023 |
| NT | 75 sqm | — | no | NT Planning Scheme 2020 |

Every row was checked against the responsible planning authority's own site on
its `last_verified` date. Where a jurisdiction publishes no single statewide
figure the column is `NULL` rather than a number borrowed from a builder's
website — see the `NULL` semantics gap below before reading those blanks.

Two rows deserve explanation:

- **QLD sets no statewide standard at all.** The Planning Act 2016 leaves both
  size and assessment pathway to each local government's planning scheme, so
  every numeric column is `NULL` and `data_status` is `no_rule_at_this_level`.
  The only statewide rule is the 2022 amendment removing occupancy restrictions.
  Queensland's real numbers live in `regulation_lga_granny_flat_rules`.
- **`default_permit_required` measures whether approval is needed at all, not
  how fast it is.** NSW is `true` because a complying development certificate is
  still issued; VIC, WA, TAS and NT are `false` because the requirement is
  removed outright when the conditions are met.

`UNIQUE (state, category, rule_type)` is the natural key. Note that
`default_permit_required` is not a measure of speed but of whether approval is
needed at all: NSW is `true` because a complying development certificate is still
issued, while VIC is `false` because the permit requirement is removed outright.

The VIC row was verified against
[Planning Victoria](https://www.planning.vic.gov.au/guides-and-resources/strategies-and-initiatives/small-second-dwellings)
on 2026-08-11: Amendment VC253, gazetted 14 December 2023, is the correct
instrument, and the 300 sqm minimum lot size is confirmed for the residential
zones.

### Completed — Regulation module, council-level subset

`regulation_lga_granny_flat_rules` mirrors the state table plus `lga_name`,
`planning_scheme` and `varies_from_state`. Queensland depends on it entirely;
elsewhere a row should exist only where a council departs from the state
position, so **the absence of a row means the state rule applies**.

Seeded with four Queensland councils, of which two are fully verified:

| Council | Status | Notes |
| --- | --- | --- |
| City of Moreton Bay | verified | Limit steps with lot area (45 / 55 / 100 sqm), hence three rows |
| City of Gold Coast | verified | Accepted development at 80 sqm; no minimum lot size in the code |
| Brisbane City Council | partial | Approval pathway unresolved — see below |
| Logan City Council | partial | Approval pathway sits in Part 5, served only via the council's enquiry tool |

Brisbane is the instructive one. Council material says prescribed accepted
development (Table 5.3.4.1) does not extend to secondary dwellings, yet
Brisbane defines a dwelling house as including one with a secondary dwelling,
and a dwelling house is generally accepted development under the Part 5 zone
tables. Those are two different mechanisms and the reachable sources do not
settle which governs, so `default_permit_required` stays `not_researched`
rather than being resolved by inference. Recording the ambiguity is the point:
a guess here would be indistinguishable from a fact once it is in the table.

Still to add: Sunshine Coast, Ipswich, Townsville, Cairns, Toowoomba, Redland,
and the remaining Queensland councils.

Note that published builder and comparison sites disagree with the councils'
own schemes on these numbers — Moreton Bay is widely quoted as 90 sqm when the
scheme says 45/55/100. Seed only from the responsible authority's own document.

### Completed — Cost module, granny flat subset

Two tables. `cost_granny_flat_build_ranges` holds build cost by state, size and
specification; `cost_site_works_items` holds the twelve site works that get
added to it.

They are split because the market's characteristic distortion is a base price
presented as a total — site costs commonly add 25–50%. On the figures currently
loaded, the priced site items sum to **$53,500–$138,500** if every one applied.
That number is the reason the module exists: it cannot be expressed as a
footnote on a build price, so it gets its own table and always travels with the
price.

`cost_basis` is `NOT NULL`. A price whose basis is unstated is worse than no
price, because it reads as a total. Western Australia is the clearest case for
that column: the same 60 sqm dwelling is quoted at about $76,000 supplied as a
module, $105,000–200,000 built on site, and $130,000–250,000 all-in. Those are
not competing estimates — they cover different things.

All eight jurisdictions carry a two-bedroom mid-spec range, which is what lets
`compare_states` report money. The comparison ships with a warning attached,
because the bases genuinely differ: QLD and WA are quoted all-in and so look
more expensive than states quoted before site costs, which they are not.

### Size and specification grid

23 ranges spanning studio to three bedrooms and basic to premium:

| Bedrooms | basic | mid | premium |
| --- | --- | --- | --- |
| 0 (studio, 25–35 sqm) | national | — | — |
| 1 (35–60 sqm) | ACT, TAS | national, ACT, NSW, SA, TAS | — |
| 2 (~60 sqm) | WA | all 8 + national | national, ACT, WA |
| 3 (80–100 sqm) | — | national, QLD | — |

**Size is a band, and bedrooms carries the natural key.** Sources quote "1
bedroom, 45–60 sqm" — the market segments on bedroom count and a size range
follows. The original `dwelling_size_sqm` forced that into a point value,
inventing a precision no source claimed; it is now `size_min_sqm` /
`size_max_sqm` plus `bedrooms`.

Two facts a budget estimator must not get wrong, both returned on every call:

- **Cost does not scale with floor area.** Kitchen, bathroom and service
  connections are near-fixed, so per-sqm cost *rises* as the dwelling shrinks. A
  studio is not half a two-bedroom at half the size, and a `$/sqm × area`
  estimator will be wrong at the small end.
- **A price for an illegal size is worse than no price.** A three-bedroom around
  90 sqm exceeds the secondary dwelling cap in NSW, VIC and TAS (60 sqm) and in
  Brisbane (80 sqm). Only the ACT permits 90 sqm outright. The three-bedroom
  rows are priced because the market prices them, not because they can be
  approved as granny flats — the tool says so, and points at the regulation
  module.

### A migration bug worth remembering

Changing the natural key from size to bedrooms orphaned every pre-existing row.
The first migration backfilled `size_min`/`size_max` but not `bedrooms`, so the
old rows fell outside the new key: the seed's upsert stopped matching them and
inserted duplicates alongside instead of updating, silently doubling the table.

Two fixes, both in `MODULE_MIGRATIONS`: the backfill now sets `bedrooms` too, so
a database migrating for the first time never orphans anything; and a repair
statement deletes rows that have `NULL` bedrooms *and* a keyed equivalent
superseding them — a row with genuinely unknown bedrooms and no sibling is left
alone. The general lesson: **when a natural key changes, the migration has to
backfill every column the new key touches, or the upsert silently stops being an
upsert.**

Two rows are worth reading before trusting the label over the detail:

- **SA** covers design fees, slab, construction, fit-out and both consents, but
  excludes utility connections ($6,000–15,000 separately). None of the three
  `cost_basis` values fits exactly; the `includes` / `excludes` arrays carry
  what the label cannot.
- **NT** is structurally, not cyclically, more expensive: cyclone-rated
  construction for 316 km/h wind, mandatory air conditioning and termite
  barriers, freight, and trades at 110–135% of Sydney rates. Those drivers are
  recorded as NT-scoped rows in `cost_site_works_items` with **no dollar
  amounts** — confirmed to exist, unresearched in size.

Which exposed a bug worth keeping in mind for any future total: unpriced rows
sum to zero, so a naive total silently understates by exactly the items nobody
has costed. `site_works_total` now counts them, names them, and labels the
total a floor.

### What the second module taught us about the architecture

The multi-module design held, but not unchanged. Three things transferred
intact: the `<module>_<subject>` naming, the three-way `NULL` semantics, and
natural-key upserts. Two things did not:

- **`data_status` is necessary but not sufficient.** It answers "has anyone
  researched this?" Cost data needs a second, orthogonal axis: a builder's
  advertised price is *fully researched* and still the least reliable number
  available, because the figure is marketing. `source_type`
  (`builder_advertised` / `industry_estimate` / `market_survey`) carries that,
  and `costs.py` renders it into a sentence a consumer can relay. Regulation
  never needed it — its source was always the instrument itself.
- **`UNIQUE` needs `NULLS NOT DISTINCT`.** The regulation keys had no nullable
  columns. Cost keys do: a national figure has no state, a statewide one has no
  region. Under Postgres's default handling every such row is distinct, so the
  upsert would insert duplicates forever instead of updating.

Two new integrity rules came with the module: an inverted range (`low > high`)
is rejected, because it produces a plausible-looking answer with the numbers the
wrong way round; and equal bounds are accepted, because a fixed price is
legitimate.

### Planned modules

- **Finance** — construction loan structures, per-lender policy, progress payment
  schedules, current rates.
- **Supplier** — suppliers, product lines, specifications, certifications, reviews.
- **Contract** — standard clauses, variation and delay provisions, common disputes.
- **Construction** — lead times, transport and craneage, install sequence, typical
  causes of delay.

---

## Setup

Requires PostgreSQL 14+ and [uv](https://docs.astral.sh/uv/).

```bash
uv sync
```

Copy `.env.example` to `.env` and set `DB_PASSWORD` to your PostgreSQL password.
`.env` is gitignored and must never be committed; `db.py` is the only place
credentials are read, and every script imports from it.

```bash
uv run setup_db.py          # create the database, tables, migrations, constraints
uv run insert_rules.py      # seed the state-level granny flat rules
uv run insert_lga_rules.py  # seed the council-level rules
uv run insert_cost_ranges.py # seed the cost module
uv run query_rules.py       # read them back
```

All four are safe to re-run. `setup_db.py` creates only what is missing and
applies migrations that are no-ops once current; the seed scripts update
existing rows in place instead of duplicating them.

## Tests

```bash
uv run pytest                    # everything
uv run pytest -m "not protocol"  # skip the subprocess tests (faster)
```

96 tests against the live development database. Anything a test writes is
marked and purged by the `conn` fixture, so a failure cannot leave the knowledge
base in a state where a later query returns invented data.

| File | Guards |
| --- | --- |
| `test_null_semantics.py` | The three-way `NULL` logic — the safety-critical part |
| `test_constraints.py` | Every database integrity rule, and that each rejects the value it exists for |
| `test_seeds.py` | Setup and seeds are idempotent; coverage and data invariants hold |
| `test_mcp_tools.py` | The tools' guardrails, called directly |
| `test_estimator.py` | Double-counting, the legality cross-check, and refusing to invent a missing combination |
| `test_mcp_protocol.py` | A real stdio handshake — proves a client can actually reach them |

**The suite asserts guardrails, not numbers.** Costs and rules change as
research continues; a test pinned to `$180,000` would break on every legitimate
update and teach the next person to ignore failures. What must not change is
that a `not_researched` field is never presented as a fact, that a price never
arrives without its basis, and that Queensland is never silently dropped from a
ranking.

Verified by mutation: collapsing `not_researched` into `not_applicable`, making
every `NULL` read as `no_rule_at_this_level`, and dropping site works from the
cost payload are each caught by a failing test. A suite that passes but survives
those would be worse than none, because it would look like protection.

## MCP server

`mcp_server.py` exposes the knowledge base over the Model Context Protocol
(stdio transport, built on the official `mcp` SDK):

```bash
uv run mcp_server.py
```

| Tool | Answers |
| --- | --- |
| `get_granny_flat_rules(state, lga?)` | "Can I build one in X, how big, and do I need approval?" — returns the state rule plus any council rules |
| `compare_states()` | "Which state is easiest?" — cross-jurisdiction comparison |
| `get_granny_flat_costs(state?, bedrooms?, modular_site_costs_only?)` | "What does it cost?" — build ranges **and** the site works added to them, always together |
| `estimate_budget(state, bedrooms, spec_level, site_conditions?, is_modular?)` | "What will *my* project cost?" — composes both modules into one figure |
| `check_data_freshness(max_age_months)` | "Is this still current?" — spans both modules |

### The estimator

`estimate_budget` is the first tool that composes the two modules rather than
reading one. It takes the block's conditions (`sloping_site`,
`reactive_clay_soil`, `services_over_15m`, `separate_vehicle_access`,
`separate_tenancy`, `site_not_flat_or_clear`) and returns a range with the
applicable site works folded in.

Two ways it could be confidently wrong, both guarded:

- **Double-counting.** Adding site works to a `turnkey_incl_site` quote inflates
  it by tens of thousands; omitting them from `turnkey_excl_site` or
  `base_module` understates it by the same. `BASIS_NEEDS_SITE_WORKS` decides,
  and the response says which happened and why. On the current data NSW, QLD and
  WA are quoted all-in and get nothing added; VIC, SA, TAS, ACT and NT do.
- **Pricing something that cannot be built.** Every estimate carries a
  `size_legality` verdict checked against the state's cap — `within_the_cap`,
  `over_the_cap`, or `cannot_check_at_state_level` for Queensland, where the cap
  lives with the council. A NSW three-bedroom returns a price *and* the fact
  that 100 sqm exceeds the 60 sqm cap.

A combination with no data returns `found: false` and lists what is on record,
rather than scaling a neighbouring size or spec into an invented figure.
Unrecognised site conditions are reported back, because silently dropping one
produces an estimate that looks complete and is not.

Site works carry a machine-readable `trigger_condition` alongside the prose
`applies_when`, so the estimator selects them from the data rather than from a
mapping hardcoded in Python — a new item becomes selectable by being inserted.

**The server's contract is that a null is never a fact.** Every null comes back
with a `why_missing` entry naming one of the three reasons above, and any field
whose value is genuinely unknown is also listed in `must_not_state`. A model
relaying an answer has to work against the payload to invent a figure, rather
than merely failing to notice a blank. Day counts always travel with their
`_basis`, and every rule carries `source` and `last_verified`.

`rules.py` holds the read layer and the `why_null` logic. Both the CLI and the
MCP server go through it, so the two can never disagree about what a blank cell
means — this is the one piece of logic worth centralising, because the failure
it prevents is a fabricated regulatory answer. `costs.py` is the cost module's
equivalent and reuses `rules.why_null` rather than reimplementing it.

The cost tool deliberately has no way to return a build price on its own. Site
works come back on every call, with their total, because quoting a base price
without them is precisely how this market misleads people.

To register it with an MCP client, point the client at
`uv run mcp_server.py` with this directory as the working directory. The server
reads credentials from `.env` like every other script.

## Querying

```bash
uv run query_rules.py          # every state
uv run query_rules.py NSW      # one state
uv run query_rules.py nsw vic  # several
```

---

## Conventions for adding a module

1. **Name the table `<module>_<subject>`.** Use the module prefixes listed above;
   add a new prefix only for a genuinely new domain, and document it here.
   Table and column names are `snake_case`, table names plural where the row is
   one of many (`..._rules`, `..._lenders`).

2. **Put units in the column name.** `max_floor_area_sqm`, `fallback_max_days`,
   `deposit_pct`. A bare `area` or `days` column will eventually be misread.

3. **Every fact-bearing table gets `source` and `last_verified`.** Regulations,
   rates and prices all go stale. A row without provenance cannot be trusted by
   the MCP server and should not be inserted.

4. **Scope by jurisdiction where it varies.** Add `state` (and `lga` if the rule
   operates at council level) rather than storing a national average that is wrong
   everywhere.

5. **Distinguish `NULL` from zero and from false.** `NULL` means not applicable or
   not yet researched; encode "no permit needed" as `default_permit_required =
   false`, not as a missing row.

6. **Add the DDL to `MODULE_TABLES` in `setup_db.py`** so a fresh clone builds the
   whole schema in one command. Keep the DDL `CREATE TABLE IF NOT EXISTS`.

7. **One seed script per module subset**, named for what it loads
   (`insert_rules.py`, later `insert_lender_policies.py`). Make it idempotent by
   keying on the table's natural key so re-running never duplicates data.

8. **Import connections from `db.py`.** Do not build a connection string or read
   `os.environ` anywhere else.

10. **Add tests with the module, not after it.** At minimum: the constraints
    reject what they exist for, the seed is idempotent, and every `NULL` the
    module can produce resolves to the right one of the three reasons. Assert
    guardrails rather than figures, so the suite survives research.

9. **Give every table a natural-key `UNIQUE` constraint** and register it in
   `MODULE_CONSTRAINTS` in `setup_db.py`. Seed scripts then use
   `ON CONFLICT (...) DO UPDATE`, which is atomic and safe to re-run. Enforcing
   the key in the database rather than in Python matters because the MCP server,
   bulk imports and manual SQL all write to these tables too, and none of them
   go through the seed scripts.

### Reading a `NULL`

A blank cell is never self-explanatory, so two columns carry the reason:

| | meaning | how to read a `NULL` |
| --- | --- | --- |
| column named in `unresearched_fields` | nobody has looked it up | **unknown — never present as fact** |
| `data_status = 'no_rule_at_this_level'` | the jurisdiction legislates elsewhere | look one level down (QLD → councils) |
| otherwise, `data_status = 'verified'` | researched, genuinely absent | not applicable |

`data_status` is one of `verified`, `partial` (some fields still in
`unresearched_fields`) or `no_rule_at_this_level`. The distinction matters most
to the MCP server: telling a Queensland user "no permit required" because a
column was `NULL` would be a fabricated answer, and this is what stops it.

### Day counts

`fast_track_days` and `fallback_max_days` are meaningless without
`*_days_basis`, which is `business` or `calendar`. NSW, VIC, WA and TAS count
calendar days; SA, QLD and ACT count business days. 35 business days is roughly
49 calendar days, so mixing them silently breaks any duration arithmetic. A
`CHECK` constraint refuses a day count whose basis is missing.

Where a jurisdiction publishes two periods, the **longer** one is stored and the
shorter is noted in `source` — better to under-promise on a timeline. NT stores
no figure at all because the Development Consent Authority decides at monthly
meetings rather than within a set period.

Where a figure is assembled from published components rather than quoted whole,
`source` shows the arithmetic so a reader can audit it. South Australia is the
example: its 12 and 55 business days are each a sum of a verification period, an
assessment period, and a decision-issuing period, plus an extension. Both
figures replaced an earlier single value of 25 that had been attached to the
wrong pathway — 25 is the performance assessed base, not the deemed-to-satisfy
period. Attaching a real number to the wrong pathway is the same class of error
as inventing one, and it is harder to spot.

## Known schema gaps

Recorded rather than fixed, so the next person does not mistake them for
oversights. Each needs a schema change, not just more rows:

- **No zone dimension.** Victoria's exemption is written per zone (RGZ, GRZ,
  NRZ, plus rural zones), and the 300 sqm lot threshold applies to those
  residential zones specifically. WA, SA, TAS and NT all set minimum lot size
  and sometimes maximum floor area per zone too, which is why those cells are
  blank. Moreton Bay is currently modelled with one row per lot-size band, which
  works but would not survive a council that varies by zone *and* by band.
- **"Researched, but no published figure" has no encoding.** NT's missing
  decision period is recorded as `verified` with an explanation in `source`,
  because it is neither unresearched nor truly inapplicable. If this case recurs
  it deserves its own `data_status` value.
- **One row cannot express a threshold split.** Victoria is really two rules:
  over 300 sqm the permit exemption applies; at or under 300 sqm a permit is
  always required and Clause 54 / VicSmart applies (Amendment VC282, in
  operation 8 September 2025). This should become a second row with its own
  `rule_type` once the sub-300 sqm pathway is researched.
- **Pending changes are recorded only as prose.** Tasmania's SPP amendment
  01/2026 would raise 60 sqm to 90 sqm but is not gazetted; that fact currently
  lives in a sentence inside `source`. Anything that needs to answer "what is
  changing soon" needs real columns.
- **Council coverage is partial, and absence is ambiguous.** The convention is
  that a missing council row means the state rule applies — but for a council
  nobody has researched, it equally means "unknown". The MCP server says so in
  words on every response; the schema cannot yet distinguish the two.
- **Only negative conditions are modelled.** `excluding_conditions` captures what
  disqualifies a site. Victoria also imposes positive conditions — no more than
  one existing dwelling on the lot, only one small second dwelling, and no
  reticulated natural gas connection — which have nowhere to live yet.
- **One row cannot express a threshold split.** Victoria is really two rules: over
  300 sqm the permit exemption applies; at or under 300 sqm a permit is always
  required and Clause 54 / VicSmart applies (Amendment VC282, in operation
  8 September 2025). This should become a second row with its own `rule_type`
  once the sub-300 sqm pathway is researched.

TDQS

A4.4/5.0

Scored across 3 tools

Disambiguation5/5

Each tool serves a distinct purpose: checking data freshness, fetching rules for a specific state, and comparing across states. There is no overlap or ambiguity in what they do.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern (check_data_freshness, get_granny_flat_rules, compare_states). The naming style is uniform and predictable.

Tool Count5/5

With 3 tools, the set is well-scoped for a niche domain like Australian granny flat regulations. Each tool is necessary and focused, fitting within the ideal 3-15 range.

Completeness5/5

The domain is fully covered: fetching specific rules, comparing all states, and ensuring data freshness. There are no obvious gaps for a read-only regulatory lookup service.

Maintenance

ActivitySlowing
ResponsivenessNo issues