Report sharing between companies
A company that has submitted an emissions report can grant another company read access to it for a given reporting year — and a buyer can request access from a supplier. Access is per (owner company, grantee company, reporting year).
Why it exists
Section titled “Why it exists”A supplier’s real reported figures are far better than a spend-based estimate, but the supplier owns that data and must consent. Sharing is what turns an “EEIO Estimate” row in a buyer’s supply chain table into a “Supplier reported” row — see [[supply-chain-emissions]]. Two directions are supported because in practice either party may start the conversation.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/themes/carboncalculator/page-share-carbon-emissions-report.php | The page, /my-company-emissions/share-carbon-emissions-report/ |
wp-content/themes/carboncalculator/assets/js/share-report-modal.js | Invite / request / respond UI |
wp-content/plugins/carboncalc/includes/ReportShare.php | All share logic |
wp-content/plugins/carboncalc/includes/Rest.php:159 | GET /report-shares — dashboard lists |
wp-content/plugins/carboncalc/includes/Rest.php:165 | GET /report-shares/shareable-years |
wp-content/plugins/carboncalc/includes/Rest.php:171 | GET /report-shares/companies — share targets |
wp-content/plugins/carboncalc/includes/Rest.php:187 | POST /report-shares/invite (owner grants) |
wp-content/plugins/carboncalc/includes/Rest.php:193 | POST /report-shares/request (grantee asks) |
wp-content/plugins/carboncalc/includes/Rest.php:199 | POST /report-shares/{id}/respond |
wp-content/plugins/carboncalc/includes/ReportShare.php:126 | grantee_has_active_access_to_report() — the read gate |
How it works
Section titled “How it works”Data model
Section titled “Data model”cc_report_share on the Carbon blog (DDL at
plugins/carboncalc/includes/Migrations.php:16):
| Column | Meaning |
|---|---|
owner_company_id | Company whose report is shared |
grantee_company_id | Company receiving access |
reporting_year_id | Which year |
status | pending | active | declined | revoked |
initiator | grantee (requested) or owner (invited) |
created_by_lms_id, updated_by_lms_id | Audit |
UNIQUE KEY owner_grantee_year — exactly one row per triple, reused across
state changes rather than inserting a new one.
Note it references companies, not reports. Access is granted to “whatever report that company has for that year”, resolved at read time.
State transitions
Section titled “State transitions”stateDiagram-v2 [*] --> pending: grantee requests [*] --> active: owner invites pending --> active: owner approves pending --> declined: owner declines pending --> declined: grantee cancels active --> revoked: owner revokes declined --> pending: grantee requests again revoked --> pending: grantee requests again revoked --> active: owner invites again
ReportShare::respond() (ReportShare.php:462) enforces who may do what:
approve, decline and revoke require the actor to be the owner company;
cancel requires the actor to be the grantee. A cancel is stored as
declined, not a distinct status (ReportShare.php:544).
bulk_invite_as_owner() (:288) creates rows directly as active — an
owner-initiated share needs no acceptance from the grantee.
Guard rails
Section titled “Guard rails”- Only submitted reports can be shared. Both invite and the
shareable-years list check
owner_has_submitted_report_for_year()(:214), and the error message names the year (submitted_report_required_error_message(),:567). - Share targets are CC Partner companies only.
list_company_candidates()filters onis_partner = 1(ReportShare.php:182), excludes the actor’s own company, and caps at 500. - A grantee cannot request from itself (
:396). - Sharing is behind a feature flag:
sharing_feature_error()(Rest.php:2632) short-circuits the endpoints whencarboncalc_feature_sharing_enabledis off, and the “Share with clients” button is hidden on the dashboard. - Sharing requires an edit role:
Share My Company Emissions datamaps to Carbon Reporter or School Admin, andscs_cc_adminis explicitly excluded (plugins/carboncalc/includes/RolePermissions.php:594).
Reading a shared report
Section titled “Reading a shared report”Rest::resolve_report_access_context() (Rest.php:291) is the only path in:
- If the id looks like a UUID and the report belongs to the caller’s
company →
ownermode. - If the UUID matches any report and
ReportShare::grantee_has_active_access_to_report()is true →granteemode. - If the id is numeric and belongs to the caller’s company →
owner. - Otherwise
null→ 404.
So a numeric id can only ever resolve to owner mode. Grantees must use
the UUID; the comment at Rest.php:287 states this deliberately.
grantee_has_active_access_to_report() joins the report to the share on
both owner_company_id = report.company_id and
reporting_year_id = report.reporting_year_id, requiring
status = 'active' (ReportShare.php:144). A share for 2025 gives no
access to the same company’s 2026 report.
report_can_edit_row() returns false for anything but owner mode
(Rest.php:229), so grantee access is read-only everywhere by construction.
Configuration
Section titled “Configuration”| Option | Effect |
|---|---|
carboncalc_feature_sharing_enabled | Master switch; default off |
Read via FeatureSettings::is_sharing_enabled(), which resolves against the
Carbon subsite’s options table. No constants.
Invariants and gotchas
Section titled “Invariants and gotchas”- A share is company-to-company, not report-to-company. If the owner deletes and recreates their report for that year, the existing share silently applies to the new report.
- Owner invites bypass consent — they land as
activeimmediately. Only grantee-initiated requests pass throughpending. cancelis recorded asdeclined. Reporting on “how many suppliers refused” will over-count, because grantee cancellations are indistinguishable from owner declines.initiatoris the only clue.- Revoking does not delete the row, so a revoked pair can be re-requested and
the history of the original grant is overwritten in place — there is no
audit trail beyond
updated_by_lms_idandupdated_at. bulk_invite_as_owner()skips grantee/year pairs that are alreadyactiveand counts nothing for them; thecreated/updatedcounts in the response will not add up to the number of pairs submitted.- The table is created twice in
Migrations: once through the sharedreport_share_table_ddl()helper insideensure_carbon_site_cc_tables()(:682) and once in the v14 gated block (:296). Both use the same helper, so keep DDL changes in that one function. - Sharing turned off mid-flight does not revoke existing shares; it only
blocks the endpoints. Existing
activerows still grant read access throughresolve_report_access_context(), which does not check the flag.
Changing it safely
Section titled “Changing it safely”- New statuses: add the constant, include it in
valid_statuses()(ReportShare.php:30), add the transition torespond(), and check everystatus = 'active'query —grantee_has_active_access_to_report()is the one that matters for security. - New share scopes (e.g. share a single report rather than a year) means
changing the unique key and the join in
grantee_has_active_access_to_report(). Do not add a second access path; keepresolve_report_access_context()as the only entry. - Notifications do not exist. A pending request is only visible when the
owner opens the share page. If you add email, hook it in
request_access()andrespond(), not in the REST layer, so bulk invites are covered too. - Verify by hand with two companies: submit a report as A, invite B, confirm B can open the report by UUID but gets 404 on the numeric id and 403 on any write; then revoke and confirm B gets 404.
None. ReportShare::respond() is a pure state machine over a row and is the
best candidate for a first test.
Related: [[supply-chain-emissions]], [[emissions-calculation-and-submission]], [[carbon-access-gates]].